diff --git a/.github/PULL_REQUEST_TEMPLATE/general.md b/.github/PULL_REQUEST_TEMPLATE/general.md
index 86a23966671..aafd968ee0f 100644
--- a/.github/PULL_REQUEST_TEMPLATE/general.md
+++ b/.github/PULL_REQUEST_TEMPLATE/general.md
@@ -4,9 +4,20 @@
Drop every section you have nothing to put in, heading included: a bare "## Relevant issues" or
"## Affected release" with nothing under it must not appear in the final description -->
-
+ No bullets, tables, or code names here. Everything else goes inside the Details block below -->
+
+``
+
+``
Details
diff --git a/.github/workflows/create-rc-branch.yml b/.github/workflows/create-rc-branch.yml
index 5269460cc93..08ee8351fda 100644
--- a/.github/workflows/create-rc-branch.yml
+++ b/.github/workflows/create-rc-branch.yml
@@ -2,7 +2,7 @@ name: Create RC Branch
on:
schedule:
- - cron: "0 3 * * 5"
+ - cron: "0 3 * * 1"
timezone: "America/Los_Angeles"
workflow_dispatch:
diff --git a/litellm/constants.py b/litellm/constants.py
index e21751c7fd1..cd7c0176428 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -432,6 +432,7 @@ WEBSOCKET_CLOSE_REASON_MAX_BYTES: Final = 123
DEEPGRAM_DEFAULT_API_BASE: Final = "https://api.deepgram.com/v1"
NADIR_DEFAULT_API_BASE: Final = "https://api.getnadir.com/v1"
DEEPGRAM_LISTEN_DEFAULT_MODEL: Final = "nova-3"
+DEEPGRAM_SPEAK_DEFAULT_MODEL: Final = "aura-asteria-en"
BEDROCK_REALTIME_PENDING_OPENING_MESSAGE_SCOPE_KEY: Final = "litellm.bedrock_realtime.pending_opening_message"
BEDROCK_REALTIME_SESSION_COMMITTED_SCOPE_KEY: Final = "litellm.bedrock_realtime.session_committed"
@@ -675,7 +676,7 @@ request_timeout: float = (
DEFAULT_REQUEST_TIMEOUT_SECONDS if _request_timeout_from_env is None else _request_timeout_from_env
)
request_timeout_explicitly_set: bool = _request_timeout_from_env is not None
-DEFAULT_A2A_AGENT_TIMEOUT: Final[float] = get_env_float("DEFAULT_A2A_AGENT_TIMEOUT", 6000.0) # 10 minutes
+DEFAULT_A2A_AGENT_TIMEOUT: Final[float] = get_env_float("DEFAULT_A2A_AGENT_TIMEOUT", 6000.0)
AGENT_KILL_SWITCH_TIMEOUT_SECONDS: Final = 10.0
AGENT_KILL_SWITCH_RESPONSE_BODY_MAX_CHARS: Final = 2000
# Patterns that indicate a localhost/internal URL in A2A agent cards that should be
diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py
index 39368dee47c..c05407b7972 100644
--- a/litellm/integrations/anthropic_cache_control_hook.py
+++ b/litellm/integrations/anthropic_cache_control_hook.py
@@ -13,7 +13,7 @@ import copy
import itertools
import os
import re
-from collections.abc import Callable, Container, Iterable, Mapping, Sequence
+from collections.abc import Callable, Container, Iterable, Iterator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, TypeAlias, TypeVar, cast
from urllib.parse import urlparse
@@ -32,6 +32,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
tool_call_ids_answered_by_client,
tool_call_ids_answered_in,
)
+from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import string_field
from litellm.llms.anthropic.common_utils import (
is_claude_code_one_shot_subagent_request,
supports_anthropic_cache_control,
@@ -128,7 +129,7 @@ def _message_mark_reaches_provider(fields: Mapping[str, object]) -> bool:
return False
return (
not isinstance(fields.get("content"), list)
- or fields.get("role") in _ROLES_SENDING_MESSAGE_MARK_WITH_LIST_CONTENT
+ or string_field(fields, "role") in _ROLES_SENDING_MESSAGE_MARK_WITH_LIST_CONTENT
)
@@ -445,22 +446,41 @@ def _as_object_list(value: object | None) -> list[object] | None:
return _validated_object_list(value)
-def _tool_call_carries_cache_breakpoint(tool_call: object, message: object, answered_tool_ids: Container[str]) -> bool:
+def _server_tool_call_id(tool_call: object) -> str | None:
+ tool_call_id: Final = _attribute_or_key(tool_call, "id")
+ if isinstance(tool_call_id, str) and tool_call_id.startswith(ANTHROPIC_SERVER_TOOL_USE_ID_PREFIX):
+ return tool_call_id
+ return None
+
+
+def _tool_call_carries_cache_breakpoint(tool_call: object, client_tool_call_ids: Container[str]) -> bool:
if _attribute_or_key(tool_call, "cache_control") is None:
return False
+ server_tool_call_id: Final = _server_tool_call_id(tool_call)
+ return server_tool_call_id is None or server_tool_call_id in client_tool_call_ids
- return not tool_call_is_rebuilt_as_server_tool_use(
- _attribute_or_key(tool_call, "id"), _attribute_or_key(message, "provider_specific_fields"), answered_tool_ids
+
+def _server_tool_call_ids_without_a_kept_result(message: object) -> Iterator[str]:
+ provider_specific_fields: Final = _attribute_or_key(message, "provider_specific_fields")
+ tool_calls: Final = _as_object_list(_attribute_or_key(message, "tool_calls")) or ()
+ return (
+ tool_call_id
+ for tool_call in tool_calls
+ if (tool_call_id := _server_tool_call_id(tool_call)) is not None
+ and not tool_call_is_rebuilt_as_server_tool_use(tool_call_id, provider_specific_fields)
)
-def _tool_call_ids_answered_by_client_when_a_server_tool_call_is_marked(
+def _client_tool_call_ids_when_a_server_tool_call_is_marked(
messages: Sequence[object], model: str | None = None, custom_llm_provider: str | None = None
) -> frozenset[str]:
if not _marks_a_server_tool_call(messages):
return frozenset()
if model is not None and not _builds_anthropic_messages(model, custom_llm_provider):
- return tool_call_ids_answered_in(messages)
+ server_calls_sent_as_client_calls: Final = itertools.chain.from_iterable(
+ map(_server_tool_call_ids_without_a_kept_result, messages)
+ )
+ return tool_call_ids_answered_in(messages) | frozenset(server_calls_sent_as_client_calls)
return tool_call_ids_answered_by_client(messages, _keeps_mid_conversation_system(model, custom_llm_provider))
@@ -517,12 +537,7 @@ def _model_info_flags_mid_conversation_system(model: str, custom_llm_provider: s
def _is_marked_server_tool_call(tool_call: object) -> bool:
- tool_call_id: Final = _attribute_or_key(tool_call, "id")
- return (
- isinstance(tool_call_id, str)
- and tool_call_id.startswith(ANTHROPIC_SERVER_TOOL_USE_ID_PREFIX)
- and _attribute_or_key(tool_call, "cache_control") is not None
- )
+ return _server_tool_call_id(tool_call) is not None and _attribute_or_key(tool_call, "cache_control") is not None
def _tool_carries_cache_breakpoint(tool: object) -> bool:
@@ -668,7 +683,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
reserved_blocks: Final = AnthropicCacheControlHook._blocks_reserved_outside_messages(
remaining_points, external_breakpoints, openai_dialect
)
- answered_tool_ids: Final = _tool_call_ids_answered_by_client_when_a_server_tool_call_is_marked(
+ answered_tool_ids: Final = _client_tool_call_ids_when_a_server_tool_call_is_marked(
processed_messages, model, custom_llm_provider
)
breakpoints_before: Final = AnthropicCacheControlHook.count_request_cache_breakpoints(
@@ -757,7 +772,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
)
message_list: Final = tuple(messages)
client_answers: Final = (
- _tool_call_ids_answered_by_client_when_a_server_tool_call_is_marked(message_list)
+ _client_tool_call_ids_when_a_server_tool_call_is_marked(message_list)
if answered_tool_ids is None
else answered_tool_ids
)
@@ -849,7 +864,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
so earlier points win when slots are scarce.
"""
client_answers: Final = (
- _tool_call_ids_answered_by_client_when_a_server_tool_call_is_marked(messages)
+ _client_tool_call_ids_when_a_server_tool_call_is_marked(messages)
if answered_tool_ids is None
else answered_tool_ids
)
@@ -939,11 +954,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
content_count: Final = sum(1 for block in content if _carries_cache_breakpoint(block)) if content else 0
tool_calls: Final = _as_object_list(_attribute_or_key(message, "tool_calls"))
tool_call_count: Final = (
- sum(
- 1
- for tool_call in tool_calls
- if _tool_call_carries_cache_breakpoint(tool_call, message, answered_tool_ids)
- )
+ sum(1 for tool_call in tool_calls if _tool_call_carries_cache_breakpoint(tool_call, answered_tool_ids))
if tool_calls
else 0
)
diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py
index fd6b6bb4a63..58189140a98 100644
--- a/litellm/litellm_core_utils/core_helpers.py
+++ b/litellm/litellm_core_utils/core_helpers.py
@@ -315,6 +315,8 @@ _FINISH_REASON_MAP: Final[dict[str, OpenAIChatCompletionFinishReason]] = {
"model_context_window_exceeded": "length",
# Cohere
"COMPLETE": "stop",
+ "STOP_SEQUENCE": "stop",
+ "TOOL_CALL": "tool_calls",
"ERROR_TOXIC": "content_filter",
"ERROR": "stop",
# HuggingFace / Together AI
diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py
index b6574a7787d..639003c9a6a 100644
--- a/litellm/litellm_core_utils/litellm_logging.py
+++ b/litellm/litellm_core_utils/litellm_logging.py
@@ -3272,7 +3272,14 @@ class Logging(LiteLLMLoggingBaseClass):
start_time = self.start_time
if end_time is None:
end_time = datetime.datetime.now()
- if self.completion_start_time is None:
+ # A request re-dispatched to a fallback reuses this logging object but runs in
+ # its own window, so a first-token time from the previous attempt cannot belong
+ # to it. Keeping it makes the attempt report a negative TTFT.
+ first_token_seconds: Final = _timestamp_seconds(self.completion_start_time)
+ attempt_start_seconds: Final = _timestamp_seconds(start_time)
+ if first_token_seconds is None or (
+ attempt_start_seconds is not None and first_token_seconds < attempt_start_seconds
+ ):
self.completion_start_time = end_time
self.model_call_details["completion_start_time"] = self.completion_start_time
diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py
index 5fbd787b3b6..938ab585b72 100644
--- a/litellm/litellm_core_utils/prompt_templates/common_utils.py
+++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py
@@ -8,7 +8,7 @@ import io
import json
import mimetypes
import re
-from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
+from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from graphlib import CycleError, TopologicalSorter
from itertools import chain, groupby, islice
@@ -3235,3 +3235,15 @@ def text_completion_prompt_to_messages(prompt: object) -> tuple[AllMessageValues
if string_entries and len(string_entries) == len(entries):
return tuple(ChatCompletionUserMessage(role="user", content=entry) for entry in string_entries)
raise ValueError(f"`prompt` must be a non-empty string or a non-empty list of strings. Got: {prompt_type_name}.")
+
+
+def is_present_non_string(value: object) -> bool:
+ return value is not None and not isinstance(value, str)
+
+
+def invalid_type_message(field_path: str, value: object) -> str:
+ return f"Invalid type for '{field_path}': expected a string, got {type(value).__name__}"
+
+
+def is_unhashable_value_behind(error: TypeError, value: object) -> bool:
+ return not isinstance(value, Hashable) and f"unhashable type: '{type(value).__name__}'" in str(error)
diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py
index 320b4df8192..98348e434b2 100644
--- a/litellm/litellm_core_utils/prompt_templates/factory.py
+++ b/litellm/litellm_core_utils/prompt_templates/factory.py
@@ -32,6 +32,7 @@ from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import
parts_of,
place_mid_conversation_system,
split_leading_system_run,
+ string_field,
)
from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get
from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client, header_value
@@ -2023,7 +2024,7 @@ def _server_tool_answers_replaced_after(messages: Sequence[object], index: int)
following: Final = _messages_until_the_next_assistant(messages, index)
a_user_turn_remains: Final = any(
message_field(message, "role") in ("user", "tool", "function")
- and message_field(message, "tool_call_id") not in kept_server_result_ids
+ and string_field(message, "tool_call_id") not in kept_server_result_ids
for message in following
)
return _tool_call_ids_in_tool_messages(following) & kept_server_result_ids if a_user_turn_remains else frozenset()
diff --git a/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py b/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py
index 3f956b0614c..ec6de4e206d 100644
--- a/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py
+++ b/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py
@@ -85,6 +85,11 @@ def message_field(message: object, key: str) -> object:
return mapping.get(key) if mapping is not None else getattr(message, key, None)
+def string_field(message: object, key: str) -> str | None:
+ value: Final = message_field(message, key)
+ return value if isinstance(value, str) else None
+
+
def is_system_message(message: object) -> bool:
return message_field(message, "role") == "system"
diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py
index 857aa2b4fef..7f29d646ffe 100644
--- a/litellm/litellm_core_utils/streaming_handler.py
+++ b/litellm/litellm_core_utils/streaming_handler.py
@@ -349,11 +349,14 @@ class CustomStreamWrapper:
make_call: Callable | None = None,
_response_headers: dict | httpx.Headers | None = None,
count_prompt_tokens: Callable[[], int] | None = None,
+ is_mock: bool = False,
):
self.model = model
self.make_call = make_call
self.count_prompt_tokens = count_prompt_tokens
self.custom_llm_provider = custom_llm_provider
+ self.is_mock = is_mock
+ self.uses_openai_chat_stream = False
self.logging_obj: LiteLLMLoggingObject = logging_obj
self.completion_stream = completion_stream
self.sent_first_chunk = False
@@ -376,6 +379,7 @@ class CustomStreamWrapper:
self._upstream_provider: str | None = None
self.received_finish_reason: str | None = None
self.intermittent_finish_reason: str | None = None # finish reasons that show up mid-stream
+ self.received_any_finish_reason = False # a later chunk can reset intermittent_finish_reason
self.special_tokens = [
"<|assistant|>",
"<|system|>",
@@ -1597,6 +1601,7 @@ class CustomStreamWrapper:
if response_obj["is_finished"]:
self.received_finish_reason = response_obj["finish_reason"]
else: # openai / azure chat model
+ self.uses_openai_chat_stream = True
if isinstance(chunk, ChatCompletionChunk):
self._openai_handler_receives_chat_chunks = True
if any(str(choice.finish_reason) == "abort" for choice in chunk.choices or ()):
@@ -1617,6 +1622,7 @@ class CustomStreamWrapper:
return _ProviderChunkEarlyReturn(None)
completion_obj["content"] = response_obj["text"]
self.intermittent_finish_reason = response_obj.get("finish_reason", None)
+ self.received_any_finish_reason |= response_obj["finish_reason"] is not None
if response_obj["is_finished"]:
if response_obj["finish_reason"] == "error":
raise Exception(
@@ -1996,6 +2002,22 @@ class CustomStreamWrapper:
model_response.choices[0].delta.content = close_tag
return model_response
+ def _should_fail_on_clean_eof(self) -> bool:
+ return (
+ not self.is_mock
+ and self.uses_openai_chat_stream
+ and self.received_finish_reason is None
+ and self.intermittent_finish_reason is None
+ and not self.received_any_finish_reason
+ )
+
+ @staticmethod
+ def _stream_eof_error() -> httpx.RemoteProtocolError:
+ return httpx.RemoteProtocolError(
+ "Stream ended before a finish_reason was received",
+ request=httpx.Request(method="POST", url="https://docs.litellm.ai/docs"),
+ )
+
def _final_chunk_for_logging(self, processed_chunk: "ModelResponseStream") -> "ModelResponseStream | None":
if self.received_finish_reason is not None or self.intermittent_finish_reason is not None:
return processed_chunk
@@ -2181,6 +2203,13 @@ class CustomStreamWrapper:
return response
except StopIteration:
+ if self._should_fail_on_clean_eof():
+ error: Final = self._stream_eof_error()
+ eof_traceback: Final = traceback.format_exc()
+ self._record_partial_usage_for_failure()
+ threading.Thread(target=self.logging_obj.failure_handler, args=(error, eof_traceback)).start()
+ self.close()
+ self._handle_stream_fallback_error(error)
if self.sent_last_chunk is True:
complete_streaming_response: Final = self._build_complete_streaming_response()
response = self._new_usage_chunk()
@@ -2384,6 +2413,9 @@ class CustomStreamWrapper:
continue
return usage_free_chunk
except (StopAsyncIteration, StopIteration):
+ if self._should_fail_on_clean_eof():
+ await self.aclose()
+ self._log_stream_failure_and_raise(self._stream_eof_error())
return await self._finalize_completed_stream(cache_hit=cache_hit)
except httpx.TimeoutException as e: # if httpx read timeout error occues
traceback_exception = traceback.format_exc()
diff --git a/litellm/llms/a2a/common_utils.py b/litellm/llms/a2a/common_utils.py
index 633ea73bb16..f6ff6d3bc47 100644
--- a/litellm/llms/a2a/common_utils.py
+++ b/litellm/llms/a2a/common_utils.py
@@ -160,8 +160,11 @@ async def resolve_a2a_hop_auth_header(
litellm_params: Mapping[str, object],
custom_llm_provider: object,
resolve_entra_header: AgentAuthHeaderResolver = resolve_azure_ai_agent_auth_header,
+ agent_extra_headers: Mapping[str, str] | None = None,
) -> Mapping[str, str] | None:
- """Entra credentials authenticate the A2A hop only; a completion-bridge agent hands them to the model provider it bridges to."""
- if not a2a_hop_uses_entra(litellm_params, custom_llm_provider):
+ if a2a_hop_uses_entra(litellm_params, custom_llm_provider):
+ return await resolve_entra_header(litellm_params)
+ if custom_llm_provider or any(name.lower() == "authorization" for name in (agent_extra_headers or {})):
return None
- return await resolve_entra_header(litellm_params)
+ api_key: Final = litellm_params.get("api_key")
+ return {"Authorization": f"Bearer {api_key}"} if isinstance(api_key, str) and api_key else None
diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py
index f03004cec15..f198d76f72a 100644
--- a/litellm/llms/anthropic/chat/transformation.py
+++ b/litellm/llms/anthropic/chat/transformation.py
@@ -2,7 +2,9 @@ import copy
import json
import re
import time
-from collections.abc import Callable, Mapping, Sequence
+from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
+from functools import partial
+from itertools import chain
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, ClassVar, Final, NoReturn, cast
@@ -27,7 +29,10 @@ from litellm.constants import (
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params, map_finish_reason
from litellm.litellm_core_utils.prompt_templates.common_utils import (
DEFS_MAX_INLINED_BYTES,
+ invalid_type_message,
is_object_schema,
+ is_present_non_string,
+ is_unhashable_value_behind,
sanitize_input_schema_for_anthropic,
schema_fragment_ref,
schema_grammar_branch_count,
@@ -45,6 +50,8 @@ from litellm.litellm_core_utils.prompt_templates.image_handling import (
inline_remote_image_urls,
)
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import (
+ message_field,
+ parts_of,
place_mid_conversation_system,
split_leading_system_run,
)
@@ -179,6 +186,76 @@ NATIVE_OUTPUT_MAX_OPTIONAL_PARAMETERS: Final = 24
NATIVE_OUTPUT_MAX_GRAMMAR_BRANCHES: Final = 2048
+def _tool_call_ids_failing(
+ index: int, tool_calls: object, fails: Callable[[object], bool]
+) -> Iterator[tuple[str, object]]:
+ ids: Final = ((position, message_field(tool_call, "id")) for position, tool_call in enumerate(parts_of(tool_calls)))
+ return (
+ (f"messages[{index}].tool_calls[{position}].id", tool_call_id)
+ for position, tool_call_id in ids
+ if fails(tool_call_id)
+ )
+
+
+def _roles_failing(messages: Sequence[object], fails: Callable[[object], bool]) -> Iterator[tuple[str, object]]:
+ roles: Final = ((index, message_field(message, "role")) for index, message in enumerate(messages))
+ return ((f"messages[{index}].role", role) for index, role in roles if fails(role))
+
+
+def _is_non_empty_non_string(value: object) -> bool:
+ return bool(value) and is_present_non_string(value)
+
+
+def _assistant_tool_call_ids_failing(
+ messages: Sequence[object], fails: Callable[[object], bool]
+) -> Iterator[tuple[str, object]]:
+ assistant_turns: Final = (
+ (index, message) for index, message in enumerate(messages) if message_field(message, "role") == "assistant"
+ )
+ return chain.from_iterable(
+ _tool_call_ids_failing(index, message_field(message, "tool_calls"), fails) for index, message in assistant_turns
+ )
+
+
+def _tool_answer_ids_failing(
+ messages: Sequence[object], fails: Callable[[object], bool]
+) -> Iterator[tuple[str, object]]:
+ answers: Final = (
+ (index, message_field(message, "tool_call_id"))
+ for index, message in enumerate(messages)
+ if message_field(message, "role") in ("tool", "function")
+ )
+ return ((f"messages[{index}].tool_call_id", tool_call_id) for index, tool_call_id in answers if fails(tool_call_id))
+
+
+def _is_non_string_value_behind(error: AttributeError, value: object) -> bool:
+ return is_present_non_string(value) and value is error.obj
+
+
+def _fields_behind(messages: Sequence[object], error: Exception) -> Iterable[tuple[str, object]]:
+ match error:
+ case TypeError():
+ caused_error: Final = partial(is_unhashable_value_behind, error)
+ return chain(
+ _assistant_tool_call_ids_failing(messages, caused_error),
+ _tool_answer_ids_failing(messages, caused_error),
+ _roles_failing(messages, caused_error),
+ )
+ case AttributeError():
+ return _assistant_tool_call_ids_failing(messages, partial(_is_non_string_value_behind, error))
+ case _:
+ return ()
+
+
+def non_string_message_field_error(messages: Sequence[object], error: Exception) -> AnthropicError | None:
+ malformed_field: Final = next(
+ chain(_roles_failing(messages, _is_non_empty_non_string), _fields_behind(messages, error)), None
+ )
+ if malformed_field is None:
+ return None
+ return AnthropicError(status_code=400, message=invalid_type_message(*malformed_field))
+
+
def _tool_type(tool: object) -> str | None:
match tool:
case {"type": str() as tool_type}:
@@ -2370,12 +2447,18 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
# Handling anthropic API Prompt Caching
if len(anthropic_system_message_list) > 0:
optional_params["system"] = anthropic_system_message_list
- conversation: Final = place_mid_conversation_system(
- later_messages,
- supports_mid_conversation_system=supports_mid_conversation_system(
- model=model, custom_llm_provider=self.custom_llm_provider
- ),
- )
+ try:
+ conversation: Final = place_mid_conversation_system(
+ later_messages,
+ supports_mid_conversation_system=supports_mid_conversation_system(
+ model=model, custom_llm_provider=self.custom_llm_provider
+ ),
+ )
+ except TypeError as error:
+ field_error: Final = non_string_message_field_error(messages, error)
+ if field_error is None:
+ raise
+ raise field_error from error
# Format rest of message according to anthropic guidelines
try:
anthropic_messages = anthropic_messages_pt(
@@ -2385,7 +2468,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
drop_params=drop_params,
)
except Exception as e:
- raise AnthropicError(
+ conversion_field_error: Final = non_string_message_field_error(messages, e)
+ raise conversion_field_error or AnthropicError(
status_code=400,
message=f"{e}\nReceived Messages={request_messages}",
) # don't use verbose_logger.exception, if exception is raised
diff --git a/litellm/llms/anthropic/messages/adapters/handler.py b/litellm/llms/anthropic/messages/adapters/handler.py
index b3b84f721c0..d13fd4f47ff 100644
--- a/litellm/llms/anthropic/messages/adapters/handler.py
+++ b/litellm/llms/anthropic/messages/adapters/handler.py
@@ -118,9 +118,10 @@ def _extract_proxy_litellm_metadata(
def _requested_router(kwargs: Mapping[str, object]) -> "Router | None":
from litellm.router import Router
+ from litellm.router_python_core import PythonCore
requested: Final = kwargs.get("litellm_router")
- return requested if isinstance(requested, Router) else None
+ return requested if isinstance(requested, (Router, PythonCore)) else None # pyright: ignore[reportReturnType] # the router passes its core, which carries Router's members
def request_safeguards_evaluator(
diff --git a/litellm/llms/anthropic/messages/context_management/editors/clear_tool_uses.py b/litellm/llms/anthropic/messages/context_management/editors/clear_tool_uses.py
index fabc112d443..f1d50fb75e2 100644
--- a/litellm/llms/anthropic/messages/context_management/editors/clear_tool_uses.py
+++ b/litellm/llms/anthropic/messages/context_management/editors/clear_tool_uses.py
@@ -74,6 +74,7 @@ class _ClearToolUsesFields(BaseModel):
clear_tool_inputs: bool | tuple[str, ...] | None = None
+_BLOCK: Final = TypeAdapter(Mapping[str, object])
_BLOCKS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
# api.anthropic.com, measured 2026-10-19 and 2026-10-28: its wording for the shapes pydantic words differently
_API_MESSAGES: Final = MappingProxyType(
@@ -317,12 +318,10 @@ def _clear_tool_results(
new_blocks: list[object] = []
mutated = False
for block in content:
- if (
- isinstance(block, dict)
- and block.get("type") == "tool_result"
- and block.get("tool_use_id") in ids_to_clear
- ):
- new_blocks.append(_cleared_result(block))
+ if (parsed := _parse(_BLOCK, block)) is not None and _block_id(
+ parsed, "tool_result", "tool_use_id"
+ ) in ids_to_clear:
+ new_blocks.append(_cleared_result(parsed))
mutated = True
cleared += 1
else:
diff --git a/litellm/llms/base_llm/files/batch_records.py b/litellm/llms/base_llm/files/batch_records.py
index 070cf73cd57..87ac4bd2b93 100644
--- a/litellm/llms/base_llm/files/batch_records.py
+++ b/litellm/llms/base_llm/files/batch_records.py
@@ -19,6 +19,8 @@ def responses_batch_body_to_chat_body(
openai_request_body: Mapping[str, object],
custom_llm_provider: str | None = None,
drop_params: bool | None = None,
+ *,
+ caller_input: str | ResponseInputParam | None = None,
) -> dict[str, object]: # mutable-ok: provider transforms take the bridged chat body as a plain dict
"""
Rewrite the body of an OpenAI `/v1/responses` batch record as a Chat Completions body.
@@ -52,5 +54,6 @@ def responses_batch_body_to_chat_body(
responses_api_request=responses_request,
custom_llm_provider=custom_llm_provider,
drop_params=drop_params,
+ caller_input=caller_input,
metadata=openai_request_body.get("metadata"),
)
diff --git a/litellm/llms/bedrock/chat/agentcore/transformation.py b/litellm/llms/bedrock/chat/agentcore/transformation.py
index 18212b7390f..2982ae6cd08 100644
--- a/litellm/llms/bedrock/chat/agentcore/transformation.py
+++ b/litellm/llms/bedrock/chat/agentcore/transformation.py
@@ -544,6 +544,17 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
return AgentCoreParsedResponse(content=content, usage=usage_data, final_message=final_message)
+ @staticmethod
+ def _message_stop_chunk(model: str) -> ModelResponseStream:
+ chunk: Final = ModelResponseStream(
+ id=f"chatcmpl-{uuid.uuid4()}",
+ created=0,
+ model=model,
+ object="chat.completion.chunk",
+ )
+ chunk.choices = [StreamingChoices(finish_reason="stop", index=0, delta=Delta())]
+ return chunk
+
def _stream_agentcore_response_sync(
self,
response: httpx.Response,
@@ -553,6 +564,8 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
Internal sync generator that parses SSE and yields ModelResponse chunks.
"""
buffer = ""
+ sent_finish = False # rebind-ok: flips when a finish chunk goes out
+ message_stopped = False # rebind-ok: flips on messageStop, the only end some runtimes send
for text_chunk in response.iter_text():
buffer += text_chunk
@@ -624,7 +637,9 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
total_tokens=usage_data.get("totalTokens", 0),
),
)
+ sent_finish = True
yield chunk
+ message_stopped |= "messageStop" in event_payload
# Process final message
if "message" in data_obj and isinstance(data_obj["message"], dict):
@@ -641,12 +656,16 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
delta=Delta(),
)
]
+ sent_finish = True
yield chunk
except json.JSONDecodeError:
verbose_logger.debug("Skipping non-JSON SSE line: %s", line[:100])
continue
+ if message_stopped and not sent_finish:
+ yield self._message_stop_chunk(model)
+
def get_sync_custom_stream_wrapper(
self,
model: str,
@@ -775,6 +794,8 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
Internal async generator that parses SSE and yields ModelResponse chunks.
"""
buffer = ""
+ sent_finish = False # rebind-ok: flips when a finish chunk goes out
+ message_stopped = False # rebind-ok: flips on messageStop, the only end some runtimes send
async for text_chunk in response.aiter_text():
buffer += text_chunk
@@ -846,7 +867,9 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
total_tokens=usage_data.get("totalTokens", 0),
),
)
+ sent_finish = True
yield chunk
+ message_stopped |= "messageStop" in event_payload
# Process final message
if "message" in data_obj and isinstance(data_obj["message"], dict):
@@ -863,12 +886,16 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
delta=Delta(),
)
]
+ sent_finish = True
yield chunk
except json.JSONDecodeError:
verbose_logger.debug("Skipping non-JSON SSE line: %s", line[:100])
continue
+ if message_stopped and not sent_finish:
+ yield self._message_stop_chunk(model)
+
async def get_async_custom_stream_wrapper(
self,
model: str,
diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py
index 0624c2c65db..dcdd9e39c20 100644
--- a/litellm/llms/bedrock/files/transformation.py
+++ b/litellm/llms/bedrock/files/transformation.py
@@ -42,6 +42,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
extract_file_data,
text_completion_prompt_to_messages,
)
+from litellm.llms.anthropic.common_utils import AnthropicError
from litellm.llms.base_llm.base_utils import map_developer_role_to_system_role
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.files.batch_records import responses_batch_body_to_chat_body
@@ -347,7 +348,14 @@ class _BatchInputLine:
record: _OpenAIBatchRecord
-_BATCH_RECORD_CONVERSION_ERRORS: Final = (AttributeError, LookupError, TypeError, ValueError, BadRequestError)
+_BATCH_RECORD_CONVERSION_ERRORS: Final = (
+ AttributeError,
+ LookupError,
+ TypeError,
+ ValueError,
+ BadRequestError,
+ AnthropicError,
+)
def _batch_line_error(line_number: int, problem: str) -> BedrockError:
diff --git a/litellm/llms/cohere/common_utils.py b/litellm/llms/cohere/common_utils.py
index c964e60fac7..65531451d52 100644
--- a/litellm/llms/cohere/common_utils.py
+++ b/litellm/llms/cohere/common_utils.py
@@ -3,6 +3,7 @@ from typing import Final, Literal
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo
from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.types.llms.cohere import CohereV2StreamedToolCall
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import (
ChatCompletionToolCallChunk,
@@ -222,6 +223,7 @@ class CohereV2ModelResponseIterator:
self.content_blocks: list = []
self.tool_index = -1
self.json_mode = json_mode
+ self.tool_call_indexes_with_arguments: frozenset[int] = frozenset()
def _parse_content_delta(self, chunk: dict) -> str:
"""Parse content-delta chunks to extract text."""
@@ -234,20 +236,23 @@ class CohereV2ModelResponseIterator:
return content
return ""
- def _parse_tool_call_delta(self, chunk: dict) -> ChatCompletionToolCallChunk | None:
- """Parse tool-call-delta chunks to extract tool calls."""
- delta: Final = chunk.get("delta", {})
- tool_calls: Final = delta.get("tool_calls", [])
- if tool_calls:
- return {
- "id": tool_calls[0].get("id", ""),
- "type": "function",
- "function": {
- "name": tool_calls[0].get("name", ""),
- "arguments": tool_calls[0].get("arguments", ""),
- },
- }
- return None
+ def _parse_tool_call_delta(self, chunk: dict, index: int) -> ChatCompletionToolCallChunk:
+ """Parse tool-call-start and tool-call-delta chunks to extract tool calls."""
+ tool_call: Final = CohereV2StreamedToolCall.model_validate(chunk)
+ if tool_call.arguments:
+ self.tool_call_indexes_with_arguments = self.tool_call_indexes_with_arguments | {index}
+ return {
+ "id": tool_call.id,
+ "type": "function",
+ "function": {"name": tool_call.name, "arguments": tool_call.arguments},
+ "index": index,
+ }
+
+ def _parse_tool_call_end(self, index: int) -> ChatCompletionToolCallChunk | None:
+ """Close a tool call that streamed no arguments with an empty JSON object, as OpenAI streams one."""
+ if index in self.tool_call_indexes_with_arguments:
+ return None
+ return {"id": None, "type": "function", "function": {"name": None, "arguments": "{}"}, "index": index}
def _parse_tool_plan_delta(self, chunk: dict) -> dict | None:
"""Parse tool-plan-delta events to extract tool plan."""
@@ -301,7 +306,8 @@ class CohereV2ModelResponseIterator:
v2 format:
- Content: chunk.type == "content-delta" -> chunk.delta.message.content.text
- - Tool calls: chunk.type == "tool-call-delta" -> chunk.delta.tool_calls
+ - Tool calls: chunk.type in ("tool-call-start", "tool-call-delta") -> chunk.delta.message.tool_calls
+ - Tool call end: chunk.type == "tool-call-end" -> "{}" arguments when the call streamed none
- Tool plan: chunk.event == "tool-plan-delta" -> chunk.data.delta.message.tool_plan
- Citations: chunk.event == "citation-start" -> chunk.data.delta.message.citations
- Finish: chunk.event == "message-end" -> chunk.data.delta.finish_reason
@@ -321,8 +327,12 @@ class CohereV2ModelResponseIterator:
# Handle different chunk types
if chunk_type == "content-delta":
text = self._parse_content_delta(chunk)
- elif chunk_type == "tool-call-delta":
- tool_use = self._parse_tool_call_delta(chunk)
+ elif chunk_type in ("tool-call-start", "tool-call-delta", "tool-call-end"):
+ tool_use = (
+ self._parse_tool_call_end(index)
+ if chunk_type == "tool-call-end"
+ else self._parse_tool_call_delta(chunk, index)
+ )
elif event_type == "tool-plan-delta":
provider_specific_fields = self._parse_tool_plan_delta(chunk)
elif event_type == "citation-start":
diff --git a/litellm/llms/deepgram/common_utils.py b/litellm/llms/deepgram/common_utils.py
index 29998895bbc..77fe83b551a 100644
--- a/litellm/llms/deepgram/common_utils.py
+++ b/litellm/llms/deepgram/common_utils.py
@@ -1,16 +1,21 @@
+import json
import math
+import re
from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
from types import MappingProxyType
from typing import Final
-from urllib.parse import parse_qs, urlparse
+from urllib.parse import parse_qs, urlparse, urlsplit, urlunsplit
import httpx
import litellm
-from litellm.constants import DEEPGRAM_DEFAULT_API_BASE, DEEPGRAM_LISTEN_DEFAULT_MODEL
+from litellm.constants import DEEPGRAM_DEFAULT_API_BASE, DEEPGRAM_LISTEN_DEFAULT_MODEL, DEEPGRAM_SPEAK_DEFAULT_MODEL
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.types.passthrough_endpoints.pass_through_endpoints import WebSocketSessionUsage
from litellm.types.utils import LlmProviders
+from litellm.utils import ModelInfoLookupMiss, lookup_model_info_for_registration
_WEBSOCKET_SCHEMES: Final = MappingProxyType({"https": "wss", "http": "ws", "wss": "wss", "ws": "ws"})
DEEPGRAM_LISTEN_CALLBACK_PARAMS: Final = frozenset({"callback", "callback_method"})
@@ -199,3 +204,71 @@ def deepgram_listen_transcript(websocket_messages: Sequence[Mapping[str, object]
if frame.get("type") == "Results"
if (transcript := _final_transcript(frame)) is not None
)
+
+
+_AURA_GENERATION: Final = re.compile(r"aura(?:-\d+)?(?=-[a-z])")
+
+
+def deepgram_speak_websocket_target(api_base: str | None, query_string: str) -> str:
+ speak_url: Final = urlsplit(join_api_base(api_base or DEEPGRAM_DEFAULT_API_BASE, "speak"))
+ scheme: Final = _WEBSOCKET_SCHEMES.get(speak_url.scheme, speak_url.scheme)
+ params: Final = _first_occurrences(query_string)
+ query: Final = params if params.get("model") else params.remove("model").add("model", DEEPGRAM_SPEAK_DEFAULT_MODEL)
+ return urlunsplit((scheme, speak_url.netloc, speak_url.path, str(query), ""))
+
+
+def deepgram_speak_model(upstream_url: str) -> str:
+ models: Final = parse_qs(urlparse(upstream_url).query).get("model")
+ return models[0] if models else DEEPGRAM_SPEAK_DEFAULT_MODEL
+
+
+def deepgram_speak_requested_model(query_string: str) -> str:
+ """Read off the upstream target, so the voice a key is authorized for is the voice Deepgram receives"""
+ return deepgram_speak_model(deepgram_speak_websocket_target(None, query_string))
+
+
+def deepgram_speak_registry_key(upstream_url: str) -> str:
+ """Deepgram bills every voice of an Aura generation at one rate, so ``aura-2-thalia-en`` is priced at the
+ ``deepgram/aura-2`` row"""
+ model: Final = deepgram_speak_model(upstream_url)
+ generation: Final = _AURA_GENERATION.match(model)
+ return f"{LlmProviders.DEEPGRAM.value}/{generation.group() if generation else model}"
+
+
+def deepgram_speak_character_rate(upstream_url: str) -> float | None:
+ """Only an exact registry hit counts, so a voice is never billed at a neighbouring row's rate"""
+ registry_key: Final = deepgram_speak_registry_key(upstream_url)
+ model_info: Final = lookup_model_info_for_registration(registry_key, LlmProviders.DEEPGRAM.value)
+ if isinstance(model_info, ModelInfoLookupMiss) or model_info["key"] != registry_key:
+ return None
+ return model_info.get("input_cost_per_character")
+
+
+@dataclass(frozen=True, slots=True)
+class DeepgramSpeakMeter:
+ """Holds the rate checked when the socket opened, so a cost-map reload mid-session can't change or drop the price"""
+
+ upstream_url: str
+ character_rate: float
+
+ def measure(self, client_text: str) -> int:
+ """Counts a Speak message's text as it is sent. Deepgram also speaks, and bills, unflushed text on its own (a
+ long buffer, Close, a close frame), so counting only flushed text would let a client get audio free, while
+ counting on send can only over-bill text Deepgram discards, such as a Clear or an oversized Speak. Never
+ raises, so a malformed frame can't end the session"""
+ try:
+ message: Final[object] = json.loads(client_text)
+ except (ValueError, RecursionError):
+ return 0
+ match message:
+ case {"type": "Speak", "text": str() as text}:
+ return len(text)
+ case _:
+ return 0
+
+ def price(self, total: int) -> WebSocketSessionUsage:
+ return WebSocketSessionUsage(
+ model=deepgram_speak_model(self.upstream_url),
+ custom_llm_provider=LlmProviders.DEEPGRAM.value,
+ response_cost=total * self.character_rate,
+ )
diff --git a/litellm/llms/langgraph/chat/sse_iterator.py b/litellm/llms/langgraph/chat/sse_iterator.py
index 6fe355ad98c..380c7893c77 100644
--- a/litellm/llms/langgraph/chat/sse_iterator.py
+++ b/litellm/llms/langgraph/chat/sse_iterator.py
@@ -41,8 +41,9 @@ class LangGraphSSEStreamIterator:
return self
def __aiter__(self):
- """Initialize async iteration."""
- self.async_line_iterator = aiter_sse_lines(self.response.aiter_text())
+ """Initialize async iteration once; CustomStreamWrapper re-enters `async for` on every chunk."""
+ if self.async_line_iterator is None:
+ self.async_line_iterator = aiter_sse_lines(self.response.aiter_text())
return self
def _parse_sse_line(self, line: str) -> ModelResponseStream | None:
diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py
index ddda9e7fb46..a905bff3521 100644
--- a/litellm/llms/openai/responses/guardrail_translation/handler.py
+++ b/litellm/llms/openai/responses/guardrail_translation/handler.py
@@ -40,7 +40,11 @@ from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, TypeVar, Uni
from openai.types.responses.response_output_refusal import ResponseOutputRefusal
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
-from typing_extensions import ReadOnly, TypedDict
+from typing_extensions import (
+ ReadOnly,
+ TypedDict,
+ TypeIs, # noqa: TID251 # TypeIs reaches typing only on 3.13
+)
from litellm._logging import verbose_proxy_logger
from litellm.completion_extras.litellm_responses_transformation.transformation import (
@@ -100,6 +104,7 @@ from litellm.types.llms.openai import (
OutputTextDeltaEvent,
OutputTextDoneEvent,
ResponseAPIUsage,
+ ResponseCompactionItem,
ResponseCompletedEvent,
ResponsesAPIOptionalRequestParams,
ResponsesAPIResponse,
@@ -712,6 +717,15 @@ def _next_stream_sequence_number(responses_so_far: Sequence[object] | None) -> i
return next((n + 1 for n in sequence_numbers if isinstance(n, int)), 0)
+def _is_responses_input(
+ input_data: object,
+) -> TypeIs["str | ResponseInputParam"]: # guard-ok: str, or a list of Mapping items
+ if isinstance(input_data, str):
+ return True
+ items: Final = cast(list[object], input_data) # cast-ok: isinstance narrows to list[Unknown] only
+ return isinstance(input_data, list) and all(isinstance(item, (Mapping, ResponseCompactionItem)) for item in items)
+
+
class OpenAIResponsesHandler(BaseTranslation):
"""
Handler for processing OpenAI Responses API with guardrails.
@@ -733,8 +747,8 @@ class OpenAIResponsesHandler(BaseTranslation):
Transforms `input` (string or ResponseInputParam) and optional
`instructions` into chat completion messages.
"""
- input_data: Final = data.get("input")
- if input_data is None:
+ input_data: Final[object] = data.get("input")
+ if not _is_responses_input(input_data):
return None
messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=input_data,
diff --git a/litellm/main.py b/litellm/main.py
index 3a97fc7d5e6..78767b03eb7 100644
--- a/litellm/main.py
+++ b/litellm/main.py
@@ -1237,6 +1237,7 @@ def mock_completion(
custom_llm_provider="openai",
logging_obj=logging,
count_prompt_tokens=count_prompt_tokens,
+ is_mock=True,
)
return CustomStreamWrapper(
completion_stream=mock_completion_streaming_obj(
@@ -1246,6 +1247,7 @@ def mock_completion(
custom_llm_provider="openai",
logging_obj=logging,
count_prompt_tokens=count_prompt_tokens,
+ is_mock=True,
)
if isinstance(mock_response, litellm.MockException):
raise mock_response
diff --git a/litellm/proxy/agent_endpoints/a2a_endpoints.py b/litellm/proxy/agent_endpoints/a2a_endpoints.py
index 5e1d861e7e9..227a1b2bdf0 100644
--- a/litellm/proxy/agent_endpoints/a2a_endpoints.py
+++ b/litellm/proxy/agent_endpoints/a2a_endpoints.py
@@ -11,7 +11,7 @@ The A2A SDK can point to LiteLLM's URL and invoke agents registered with LiteLLM
"""
import json
-from collections.abc import AsyncGenerator, Mapping
+from collections.abc import AsyncGenerator, Callable, Coroutine, Mapping
from copy import deepcopy
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Protocol
@@ -19,7 +19,8 @@ from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse, StreamingResponse
-from pydantic import TypeAdapter, ValidationError
+from fastapi.routing import APIRoute
+from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_proxy_logger
@@ -27,7 +28,7 @@ from litellm.litellm_core_utils.core_helpers import as_str_mapping
from litellm.litellm_core_utils.url_utils import SSRFError, validate_url
from litellm.llms.a2a.common_utils import resolve_a2a_hop_auth_header
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines
-from litellm.proxy._types import UserAPIKeyAuth
+from litellm.proxy._types import ProxyException, UserAPIKeyAuth
from litellm.proxy.a2a.version_convert import (
A2AVersion,
normalize_agent_card,
@@ -42,6 +43,7 @@ from litellm.proxy.agent_endpoints.databricks_oauth import (
)
from litellm.proxy.agent_endpoints.utils import merge_agent_headers
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+from litellm.proxy.common_utils.openai_error_payload import status_typed_proxy_exception
from litellm.proxy.common_utils.sse_keepalive import (
SSE_COMMENT_PING,
coerce_keepalive_interval,
@@ -172,13 +174,16 @@ def _caller_identity_headers(user_api_key_dict: UserAPIKeyAuth) -> Mapping[str,
async def _resolve_backend_auth(
litellm_params: dict[str, object],
custom_llm_provider: object,
+ agent_extra_headers: Mapping[str, str] | None = None,
) -> AgentBackendAuth:
databricks_auth: Final = await resolve_databricks_backend_auth(litellm_params)
if has_databricks_oauth(litellm_params):
return databricks_auth
return AgentBackendAuth(
litellm_params=databricks_auth.litellm_params,
- header=await resolve_a2a_hop_auth_header(litellm_params, custom_llm_provider),
+ header=await resolve_a2a_hop_auth_header(
+ litellm_params, custom_llm_provider, agent_extra_headers=agent_extra_headers
+ ),
)
@@ -218,6 +223,57 @@ def _jsonrpc_error(
)
+class _JsonRpcRequestId(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ id: str | int | None = None
+
+
+async def _jsonrpc_request_id(request: Request) -> str | int | None:
+ try:
+ return _JsonRpcRequestId.model_validate_json(await request.body()).id
+ except ValidationError:
+ return None
+
+
+async def _jsonrpc_refusal(request: Request, refusal: ProxyException | HTTPException) -> JSONResponse:
+ from litellm.proxy.proxy_server import (
+ _close_dangling_otel_server_span, # pyright: ignore[reportPrivateUsage] # proxy_server keeps the span-close helper private; a route that answers an error itself must stamp the OTel server span like the global ProxyException handler does
+ )
+
+ error: Final = refusal if isinstance(refusal, ProxyException) else status_typed_proxy_exception(refusal)
+ _close_dangling_otel_server_span(request, int(error.code), exc=refusal)
+ return JSONResponse(
+ content={
+ "jsonrpc": "2.0",
+ "id": await _jsonrpc_request_id(request),
+ "error": {
+ "code": -32000,
+ "message": error.message,
+ "data": {"type": error.type, "param": error.param, "code": error.code},
+ },
+ },
+ status_code=int(error.code),
+ headers=error.headers if isinstance(refusal, ProxyException) else refusal.headers,
+ )
+
+
+class _JsonRpcRefusalRoute(APIRoute):
+ def get_route_handler(self) -> Callable[[Request], Coroutine[object, object, Response]]:
+ handler: Final = super().get_route_handler()
+
+ async def answer_refusals_as_jsonrpc(request: Request) -> Response:
+ try:
+ return await handler(request)
+ except (ProxyException, HTTPException) as refusal:
+ return await _jsonrpc_refusal(request, refusal)
+
+ return answer_refusals_as_jsonrpc
+
+
+jsonrpc_router: Final = APIRouter(route_class=_JsonRpcRefusalRoute)
+
+
async def _get_agent(agent_id: str) -> "AgentResponse | None":
"""Look up an agent by ID or name. Returns None if not found."""
from litellm.proxy.common_utils.registry_read_through import (
@@ -646,17 +702,17 @@ async def get_agent_card(
raise HTTPException(status_code=500, detail=str(e))
-@router.post(
+@jsonrpc_router.post(
"/a2a/{agent_id}",
tags=["[beta] A2A Agents"],
dependencies=[Depends(user_api_key_auth)],
)
-@router.post(
+@jsonrpc_router.post(
"/a2a/{agent_id}/message/send",
tags=["[beta] A2A Agents"],
dependencies=[Depends(user_api_key_auth)],
)
-@router.post(
+@jsonrpc_router.post(
"/v1/a2a/{agent_id}/message/send",
tags=["[beta] A2A Agents"],
dependencies=[Depends(user_api_key_auth)],
@@ -834,14 +890,15 @@ async def invoke_agent_a2a(
if header_name:
dynamic_headers[header_name] = val
- backend_auth: Final = await _resolve_backend_auth(litellm_params, custom_llm_provider)
+ merged_agent_headers: Final = merge_agent_headers(
+ dynamic_headers=dynamic_headers or None,
+ static_headers=static_headers or None,
+ )
+ backend_auth: Final = await _resolve_backend_auth(litellm_params, custom_llm_provider, merged_agent_headers)
agent_extra_headers: Final = _forwarding_headers(
caller_identity=caller_identity,
request_data=data,
- agent_extra_headers=merge_agent_headers(
- dynamic_headers=dynamic_headers or None,
- static_headers=static_headers or None,
- ),
+ agent_extra_headers=merged_agent_headers,
backend_auth_header=backend_auth.header,
)
forwarded_litellm_params: Final = backend_auth.litellm_params
@@ -1051,3 +1108,6 @@ async def invoke_agent_a2a(
if isinstance(e, litellm.BadRequestError):
return _jsonrpc_error(body.get("id"), -32602, e.message, 400)
return _jsonrpc_error(body.get("id"), -32603, f"Internal error: {e}", 500)
+
+
+router.include_router(jsonrpc_router)
diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py
index 9c0bb93ea16..e2ae25686da 100644
--- a/litellm/proxy/auth/auth_checks.py
+++ b/litellm/proxy/auth/auth_checks.py
@@ -1086,13 +1086,31 @@ def model_max_budget_of(holder: ModelMaxBudgetHolder) -> Mapping[str, object] |
def route_skips_budget_checks(route: str) -> bool:
- return route not in BUDGET_ENFORCED_SIDE_EFFECT_ROUTES and (
- route in MODEL_DISCOVERY_ROUTES or not RouteChecks.is_llm_api_route(route=route)
+ if route in BUDGET_ENFORCED_SIDE_EFFECT_ROUTES:
+ return False
+ if route in MODEL_DISCOVERY_ROUTES:
+ return True
+ if RouteChecks.is_llm_api_route(route=route):
+ return False
+ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( # noqa: PLC0415 # pass-through endpoints import the auth stack
+ InitPassThroughEndpointHelpers,
)
+ return not InitPassThroughEndpointHelpers.is_registered_pass_through_route(route=route)
-def request_skips_budget_checks(route: str, model: str | list[str] | None, llm_router: Router | None) -> bool:
- return route_skips_budget_checks(route=route) or is_model_cost_zero(model=model, llm_router=llm_router)
+
+def request_skips_budget_checks(
+ route: str,
+ model: str | list[str] | None,
+ llm_router: Router | None,
+ *,
+ request_body: Mapping[str, object] | None = None,
+) -> bool:
+ if route_skips_budget_checks(route=route):
+ return True
+ if request_body is not None and is_mcp_discovery_request(route=route, request_body=request_body):
+ return True
+ return is_model_cost_zero(model=model, llm_router=llm_router)
async def common_checks(
@@ -1142,10 +1160,11 @@ async def common_checks(
team_id=valid_token.team_id if valid_token is not None else None,
)
- skip_all_budget_checks: Final = (
- skip_budget_checks
- or route_skips_budget_checks(route=route)
- or is_mcp_discovery_request(route=route, request_body=_typed_request_body(request_body))
+ skip_all_budget_checks: Final = skip_budget_checks or request_skips_budget_checks(
+ route=route,
+ model=_model,
+ llm_router=llm_router,
+ request_body=_typed_request_body(request_body),
)
membership_user_id: Final = (
diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py
index 2b0522c09b5..a863de8b0f8 100644
--- a/litellm/proxy/auth/user_api_key_auth.py
+++ b/litellm/proxy/auth/user_api_key_auth.py
@@ -80,7 +80,6 @@ from litellm.proxy.auth.auth_checks import ( # noqa: F401 # legacy module expo
get_team_object,
get_user_object,
get_user_role,
- is_mcp_discovery_request,
is_model_cost_zero,
is_user_proxy_admin,
is_valid_fallback_model,
@@ -88,6 +87,7 @@ from litellm.proxy.auth.auth_checks import ( # noqa: F401 # legacy module expo
key_access_group_grants_model,
key_model_aliases_for_auth_check,
model_max_budget_of,
+ request_skips_budget_checks,
resolve_and_validate_end_user_id,
resolve_default_end_user_budget,
route_skips_budget_checks,
@@ -2497,15 +2497,9 @@ async def user_api_key_auth_builder(
llm_router=llm_router,
team_id=valid_token.team_id,
)
- skip_budget_checks = is_mcp_discovery_request(route=route, request_body=request_data)
- if not skip_budget_checks and model is not None and llm_router is not None:
- from litellm.proxy.auth.auth_checks import is_model_cost_zero
-
- skip_budget_checks = ( # rebind-ok: pre-existing rebinding on a rename-only line
- is_model_cost_zero(model=model, llm_router=llm_router)
- )
- if skip_budget_checks:
- verbose_proxy_logger.info("Skipping all budget checks for zero-cost model: %s", model)
+ skip_budget_checks = request_skips_budget_checks(
+ route=route, model=model, llm_router=llm_router, request_body=request_data
+ )
# Fetch project object for JWT path if project_id is set
_jwt_project_obj = None
@@ -3019,15 +3013,9 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
llm_router=llm_router,
team_id=valid_token.team_id,
)
- skip_budget_checks = is_mcp_discovery_request(route=route, request_body=request_data)
- if not skip_budget_checks and model is not None and llm_router is not None:
- from litellm.proxy.auth.auth_checks import is_model_cost_zero
-
- skip_budget_checks = is_model_cost_zero( # rebind-ok: pre-existing rebinding on a rename-only line
- model=model, llm_router=llm_router
- )
- if skip_budget_checks:
- verbose_proxy_logger.info("Skipping all budget checks for zero-cost model: %s", model)
+ skip_budget_checks: Final = request_skips_budget_checks(
+ route=route, model=model, llm_router=llm_router, request_body=request_data
+ )
# Check 3. Check if user is in their team budget
if not skip_budget_checks and valid_token.team_member_spend is not None:
@@ -4088,11 +4076,7 @@ def _should_skip_budget_checks(
llm_router=llm_router,
team_id=team_id,
)
- if is_mcp_discovery_request(route=route, request_body=request_data):
- return True
- if model is not None and llm_router is not None:
- return is_model_cost_zero(model=model, llm_router=llm_router)
- return False
+ return request_skips_budget_checks(route=route, model=model, llm_router=llm_router, request_body=request_data)
def _resolve_request_principal(request: Request, valid_token: UserAPIKeyAuth) -> Principal:
diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py
index 151938d73d7..f7f0cf2ab3b 100644
--- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py
+++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py
@@ -64,11 +64,16 @@ from litellm.llms.azure.passthrough.transformation import (
from litellm.llms.bedrock.passthrough.transformation import is_bedrock_streaming_endpoint
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.llms.deepgram.common_utils import (
+ DeepgramSpeakMeter,
deepgram_listen_callback_params,
deepgram_listen_is_priced,
deepgram_listen_registry_key,
deepgram_listen_requested_model,
deepgram_listen_websocket_target,
+ deepgram_speak_character_rate,
+ deepgram_speak_registry_key,
+ deepgram_speak_requested_model,
+ deepgram_speak_websocket_target,
)
from litellm.llms.fal_ai.cost_calculator import fal_ai_passthrough_cost, fal_ai_queue_base
from litellm.llms.nvidia_nim.passthrough.transformation import nvidia_nim_model_group_in_path
@@ -129,6 +134,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
YieldsToConfiguredPassThroughRoute,
create_pass_through_route,
create_websocket_passthrough_route,
+ truncated_close_reason,
websocket_passthrough_request,
)
from litellm.proxy.utils import ProxyLogging as ProxyLoggingType
@@ -145,6 +151,7 @@ from litellm.types.passthrough_endpoints.pass_through_endpoints import (
LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY,
LITELLM_PASS_THROUGH_DEPLOYMENT_MODEL_INFO_STATE_KEY,
LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY,
+ WebSocketSessionMeter,
)
from litellm.types.passthrough_endpoints.tinyfish import (
TINYFISH_AUTHENTICATED_RUN_FIELDS,
@@ -3460,6 +3467,7 @@ class _WebsocketRelay(Protocol):
forward_headers: bool,
endpoint: str,
accept_websocket: bool,
+ meter: WebSocketSessionMeter | None = None,
) -> None: ...
@@ -3621,6 +3629,54 @@ async def deepgram_listen_websocket_route(
)
+async def deepgram_speak_user_api_key_auth(websocket: WebSocket) -> UserAPIKeyAuth:
+ return await user_api_key_auth_websocket_for_model(
+ websocket, model=deepgram_speak_requested_model(websocket.url.query)
+ )
+
+
+@router.websocket("/deepgram/v1/speak")
+@router.websocket("/deepgram/speak")
+async def deepgram_speak_websocket_route(
+ websocket: WebSocket,
+ user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(deepgram_speak_user_api_key_auth)],
+ relay: Annotated[_WebsocketRelay, Depends(_websocket_relay)],
+) -> None:
+ deepgram_api_key: Final = passthrough_endpoint_router.get_credentials(
+ custom_llm_provider=litellm.LlmProviders.DEEPGRAM.value,
+ region_name=None,
+ )
+ if deepgram_api_key is None:
+ await websocket.close(code=1011, reason=_DEEPGRAM_WS_MISSING_KEY_REASON)
+ return
+
+ await websocket.accept(subprotocol=_negotiated_websocket_subprotocol(websocket))
+ target: Final = deepgram_speak_websocket_target(
+ api_base=get_secret_str("DEEPGRAM_API_BASE"),
+ query_string=websocket.url.query,
+ )
+ character_rate: Final = deepgram_speak_character_rate(target)
+ if character_rate is None:
+ await websocket.close(
+ code=1008,
+ reason=truncated_close_reason(
+ _DEEPGRAM_WS_UNPRICED_REASON.format(registry_key=deepgram_speak_registry_key(target))
+ ),
+ )
+ return
+
+ await relay(
+ websocket=websocket,
+ target=target,
+ custom_headers={"Authorization": f"Token {deepgram_api_key}"},
+ user_api_key_dict=user_api_key_dict,
+ forward_headers=False,
+ endpoint=websocket.url.path,
+ accept_websocket=False,
+ meter=DeepgramSpeakMeter(target, character_rate),
+ )
+
+
_OPENAI_BETA_HEADER: Final = "openai-beta"
_CLIENT_OPENAI_BETA_RESOURCES: Final = frozenset({"agents", "vaults", "chatkit"})
@@ -4474,10 +4530,16 @@ async def watsonx_proxy_route(
if provider_config is None:
raise HTTPException(status_code=404, detail="Watsonx passthrough config not found")
+ watsonx_credentials: Final = passthrough_endpoint_router.get_proxy_wide_deployment_credentials(
+ custom_llm_provider="watsonx"
+ )
+ api_key: Final = watsonx_credentials.api_key if watsonx_credentials is not None else None
+ api_base: Final = watsonx_credentials.api_base if watsonx_credentials is not None else None
+
# Get complete URL with version parameter
complete_url, _ = provider_config.get_complete_url(
- api_base=None,
- api_key=None,
+ api_base=api_base,
+ api_key=api_key,
model="",
endpoint=endpoint,
request_query_params=None,
@@ -4491,8 +4553,8 @@ async def watsonx_proxy_route(
messages=[],
optional_params={},
litellm_params={},
- api_key=None,
- api_base=None,
+ api_key=api_key,
+ api_base=api_base,
)
# Check for streaming
diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py
index bda4f4c2ad2..970cd466337 100644
--- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py
+++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py
@@ -2584,7 +2584,7 @@ def _json_object_frame(frame: str | bytes) -> dict[str, object] | None:
return decoded if isinstance(decoded, dict) else None
-def _truncated_close_reason(reason: str) -> str:
+def truncated_close_reason(reason: str) -> str:
"""
Fit a close reason inside the byte budget a WebSocket close frame allows, without splitting a character
"""
@@ -2958,7 +2958,7 @@ async def websocket_passthrough_request(
if upstream_close is not None and _client_socket_is_open(websocket):
await websocket.close(
code=upstream_close.code,
- reason=_truncated_close_reason(upstream_close.reason),
+ reason=truncated_close_reason(upstream_close.reason),
)
end_time: Final = datetime.now()
diff --git a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py
index 3b49ca1450a..e793dac4d06 100644
--- a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py
+++ b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py
@@ -132,6 +132,19 @@ class PassthroughEndpointRouter:
api_base=deployment_credentials.api_base if deployment_credentials is not None else None,
)
+ def get_proxy_wide_deployment_credentials(self, custom_llm_provider: str) -> _PassThroughCredentials | None:
+ deployment_credentials: Final = self._get_deployment_credentials(
+ custom_llm_provider=custom_llm_provider,
+ region_name=None,
+ include_team_deployments=False,
+ )
+ if deployment_credentials is None:
+ return None
+ api_key: Final = _resolve_oidc_reference(deployment_credentials.api_key)
+ if not api_key:
+ return None
+ return _PassThroughCredentials(api_key=api_key, api_base=deployment_credentials.api_base)
+
def has_credentials(self, custom_llm_provider: str) -> bool:
if (
self._get_deployment_credentials(
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index 634c709b3ca..d8a57d3e9a1 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -846,6 +846,8 @@ from litellm.proxy.telemetry.middleware import TelemetryMiddleware
from litellm.proxy.telemetry.runtime import TelemetryRuntime
from litellm.proxy.telemetry.settings import describe_errors as describe_telemetry_errors
from litellm.proxy.telemetry.settings import load_settings as load_telemetry_settings
+from litellm.router import router_arguments
+from litellm.router_config import build_router_config
try:
from litellm.proxy.enterprise_billing.billing_metrics import (
@@ -3068,6 +3070,29 @@ redis_usage_cache: RedisCache | None = None # redis cache used for tracking spe
polling_via_cache_enabled: Literal["all"] | list[str] | bool = False
native_background_mode: list[str] = [] # Models that should use native provider background mode instead of polling
polling_cache_ttl: int = 3600 # Default 1 hour TTL for polling cache
+
+
+class _ResponseBackgroundSettings(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ polling_via_cache: Literal["all"] | tuple[str, ...] | bool = False
+ native_background_mode: tuple[str, ...] = ()
+ ttl: int = 3600
+
+
+def _apply_background_response_settings(value: SettingsJsonValue) -> None:
+ global polling_via_cache_enabled, native_background_mode, polling_cache_ttl
+
+ responses: Final = _as_settings_mapping(value)
+ settings: Final = _ResponseBackgroundSettings.model_validate(responses.get("background_mode") or {})
+ polling: Final = settings.polling_via_cache
+ polling_via_cache_enabled, native_background_mode, polling_cache_ttl = ( # rebind-ok: Refresh runtime config.
+ list(polling) if isinstance(polling, tuple) else polling,
+ list(settings.native_background_mode),
+ settings.ttl,
+ )
+
+
user_custom_auth = None
user_custom_key_generate = None
# Sentinel: prevents PKCE-no-Redis advisory from re-logging on config hot-reload.
@@ -6930,20 +6955,7 @@ class ProxyConfig:
# see usage here: https://docs.litellm.ai/docs/proxy/caching
pass
elif key == "responses":
- # Initialize global polling via cache settings
- global polling_via_cache_enabled, native_background_mode, polling_cache_ttl
- background_mode = value.get("background_mode", {})
- polling_via_cache_enabled = background_mode.get("polling_via_cache", False)
- native_background_mode = background_mode.get("native_background_mode", [])
- polling_cache_ttl = background_mode.get("ttl", 3600)
- verbose_proxy_logger.debug(
- "%s Initialized polling via cache: enabled=%s, native_background_mode=%s, ttl=%s%s",
- blue_color_code,
- polling_via_cache_enabled,
- native_background_mode,
- polling_cache_ttl,
- reset_color_code,
- )
+ _apply_background_response_settings(value)
elif key == "max_ui_session_budget":
litellm.max_ui_session_budget = float(value) if value is not None else None
verbose_proxy_logger.debug(
@@ -7407,20 +7419,24 @@ class ProxyConfig:
verbose_proxy_logger.warning(
"Key '%s' is not a valid argument for Router.__init__(). Ignoring this key.", k
)
- router = litellm.Router(
- **router_params,
- assistants_config=assistants_config,
- search_tools=search_tools,
- router_general_settings=RouterGeneralSettings(
- async_only_mode=True # only init async clients
- ),
- ignore_invalid_deployments=True, # don't raise an error if a deployment is invalid
- fallback_access_check=router_fallback_access_check,
- fallback_budget_check=RouterFallbackBudgetCheck(
- is_enforced=router_fallback_budget_check.is_enforced,
- team_member_model_budget_limiter=model_max_budget_limiter,
- ),
- auto_router_capability_limit=_license_check.auto_router_capability_limit,
+ router = litellm.Router.from_config(
+ build_router_config(
+ router_arguments(
+ **router_params,
+ assistants_config=assistants_config,
+ search_tools=search_tools,
+ router_general_settings=RouterGeneralSettings(
+ async_only_mode=True # only init async clients
+ ),
+ ignore_invalid_deployments=True, # don't raise an error if a deployment is invalid
+ fallback_access_check=router_fallback_access_check,
+ fallback_budget_check=RouterFallbackBudgetCheck(
+ is_enforced=router_fallback_budget_check.is_enforced,
+ team_member_model_budget_limiter=model_max_budget_limiter,
+ ),
+ auto_router_capability_limit=_license_check.auto_router_capability_limit,
+ )
+ )
)
if redis_usage_cache is not None and router.health().store.redis_cache is None:
@@ -7910,19 +7926,23 @@ class ProxyConfig:
# Router can function with model_list=[] if search_tools are configured
if len(_model_list) > 0 or search_tools:
verbose_proxy_logger.debug("_model_list: %s", _model_list)
- llm_router = litellm.Router(
- model_list=_model_list,
- router_general_settings=RouterGeneralSettings(
- async_only_mode=True # only init async clients
- ),
- search_tools=search_tools,
- ignore_invalid_deployments=True,
- fallback_access_check=router_fallback_access_check,
- fallback_budget_check=RouterFallbackBudgetCheck(
- is_enforced=router_fallback_budget_check.is_enforced,
- team_member_model_budget_limiter=model_max_budget_limiter,
- ),
- auto_router_capability_limit=_license_check.auto_router_capability_limit,
+ llm_router = litellm.Router.from_config(
+ build_router_config(
+ router_arguments(
+ model_list=_model_list,
+ router_general_settings=RouterGeneralSettings(
+ async_only_mode=True # only init async clients
+ ),
+ search_tools=search_tools,
+ ignore_invalid_deployments=True,
+ fallback_access_check=router_fallback_access_check,
+ fallback_budget_check=RouterFallbackBudgetCheck(
+ is_enforced=router_fallback_budget_check.is_enforced,
+ team_member_model_budget_limiter=model_max_budget_limiter,
+ ),
+ auto_router_capability_limit=_license_check.auto_router_capability_limit,
+ )
+ )
)
verbose_proxy_logger.debug("updated llm_router: %s", llm_router)
else:
@@ -8643,6 +8663,7 @@ class ProxyConfig:
def _apply_litellm_settings_db_values(self, db_values: Mapping[str, SettingsJsonValue]) -> None:
self.litellm_settings.apply_db_row("litellm_settings", db_values)
+ _apply_background_response_settings(self.litellm_settings.get("responses", {}))
for key in LITELLM_SETTINGS_SAFE_DB_OVERRIDES:
if key in db_values and (value := self.litellm_settings.get(key)) is not None:
setattr(litellm, key, value)
@@ -18100,6 +18121,8 @@ async def model_deprecations(
def get_model_group_info(
llm_router: Router, all_models_str: Sequence[str], model_group: str | None, team_id: str | None = None
) -> list[ModelGroupInfoProxy]:
+ from litellm.proxy.response_polling.polling_handler import should_use_polling_for_request
+
model_groups: Final[list[ModelGroupInfoProxy]] = []
unique_models: Final = tuple(dict.fromkeys(all_models_str))
@@ -18109,6 +18132,14 @@ def get_model_group_info(
if model_group is not None and model_group != model:
continue
+ supports_background_chat = should_use_polling_for_request(
+ background_mode=True,
+ polling_via_cache_enabled=polling_via_cache_enabled,
+ redis_cache=redis_usage_cache,
+ model=model,
+ llm_router=llm_router,
+ native_background_mode=native_background_mode,
+ )
_model_group_info = registry.group(model, team_id=team_id)
if _model_group_info is not None:
@@ -18116,6 +18147,7 @@ def get_model_group_info(
ModelGroupInfoProxy.model_validate(
{
**_model_group_info.model_dump(),
+ "supports_background_chat": supports_background_chat,
**model_group_custom_fields(
registry.deployments(
resolve_model_group_alias(registry.aliases(), model) or model,
@@ -18130,6 +18162,7 @@ def get_model_group_info(
model_group_info = ModelGroupInfoProxy(
model_group=model,
providers=[],
+ supports_background_chat=supports_background_chat,
)
model_groups.append(model_group_info)
diff --git a/litellm/proxy/response_polling/background_streaming.py b/litellm/proxy/response_polling/background_streaming.py
index 1eb76f8f558..574152048d4 100644
--- a/litellm/proxy/response_polling/background_streaming.py
+++ b/litellm/proxy/response_polling/background_streaming.py
@@ -11,6 +11,7 @@ https://platform.openai.com/docs/api-reference/responses-streaming
import asyncio
import json
from collections.abc import AsyncIterable, Callable, Mapping, Sequence
+from time import perf_counter
from typing import TYPE_CHECKING, Final, Protocol, TypeAlias, runtime_checkable
from fastapi import Request, Response
@@ -128,6 +129,16 @@ async def background_streaming_task(
https://platform.openai.com/docs/api-reference/responses/object
"""
+ started_at: Final = perf_counter()
+ first_token_at: float | None = None # rebind-ok: first content event closes the TTFT interval
+ finished_at: float | None = None # rebind-ok: terminal event closes the execution interval
+
+ def timing_snapshot() -> Mapping[str, float]:
+ return {
+ **({"time_to_first_token_ms": (first_token_at - started_at) * 1000} if first_token_at is not None else {}),
+ **({"total_latency_ms": (finished_at - started_at) * 1000} if finished_at is not None else {}),
+ }
+
try:
verbose_proxy_logger.info("Starting background streaming for %s", polling_id)
@@ -191,7 +202,7 @@ async def background_streaming_task(
incomplete_details_data = None
state_dirty = False # Track if state needs to be synced
- last_update_time = asyncio.get_event_loop().time()
+ last_update_time = perf_counter()
UPDATE_INTERVAL: Final = 0.150 # 150ms batching interval
# Track the terminal event from the stream (may not be "completed")
@@ -210,13 +221,14 @@ async def background_streaming_task(
"""Flush accumulated state to Redis if interval elapsed or forced"""
nonlocal state_dirty, last_update_time
- current_time: Final = asyncio.get_event_loop().time()
+ current_time: Final = perf_counter()
if state_dirty and (force or (current_time - last_update_time) >= UPDATE_INTERVAL):
# Convert output_items dict to list for update
output_list: Final = list(output_items.values())
await polling_handler.update_state(
polling_id=polling_id,
output=output_list,
+ timing=timing_snapshot(),
)
state_dirty = False
last_update_time = current_time
@@ -242,6 +254,20 @@ async def background_streaming_task(
event: _StreamEvent = _StreamEventParser.parse(chunk_data)
event_type = event.get("type", "")
+ if (
+ first_token_at is None
+ and event_type
+ in (
+ "response.output_text.delta",
+ "response.reasoning_text.delta",
+ "response.reasoning_summary_text.delta",
+ "response.reasoning.delta",
+ )
+ and event.get("delta")
+ ):
+ first_token_at = perf_counter()
+ state_dirty = True
+
# Process different event types based on OpenAI streaming spec
if event_type == "response.output_item.added":
# New output item added
@@ -334,6 +360,7 @@ async def background_streaming_task(
):
# Terminal event - extract all ResponsesAPIResponse fields
# https://platform.openai.com/docs/api-reference/responses-streaming
+ finished_at = perf_counter()
response_data = event.get("response", {})
terminal_status = response_data.get(
"status",
@@ -384,10 +411,13 @@ async def background_streaming_task(
# Use the terminal status from the stream, default to "completed"
final_status: Final = terminal_status or "completed"
+ if finished_at is None:
+ finished_at = perf_counter() # rebind-ok: close the interval when the stream has no terminal event
await polling_handler.update_state(
polling_id=polling_id,
status=final_status,
+ timing=timing_snapshot(),
usage=usage_data,
error=terminal_error,
reasoning=reasoning_data,
@@ -417,6 +447,7 @@ async def background_streaming_task(
)
except Exception as e:
+ finished_at = perf_counter() # rebind-ok: capture execution time at failure
verbose_proxy_logger.error("Error in background streaming task for %s: %s", polling_id, e)
import traceback
@@ -425,6 +456,7 @@ async def background_streaming_task(
await polling_handler.update_state(
polling_id=polling_id,
status="failed",
+ timing=timing_snapshot(),
error={
"type": "internal_error",
"message": str(e),
diff --git a/litellm/proxy/response_polling/polling_handler.py b/litellm/proxy/response_polling/polling_handler.py
index fe224a52cfa..d66773cd048 100644
--- a/litellm/proxy/response_polling/polling_handler.py
+++ b/litellm/proxy/response_polling/polling_handler.py
@@ -3,9 +3,9 @@ Response Polling Handler for Background Responses with Cache
"""
import json
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
-from typing import Final
+from typing import TYPE_CHECKING, Final
from pydantic import TypeAdapter
@@ -15,6 +15,9 @@ from litellm._uuid import uuid4
from litellm.caching.redis_cache import RedisCache
from litellm.types.llms.openai import ResponsesAPIResponse, ResponsesAPIStatus
+if TYPE_CHECKING:
+ from litellm.router import Router
+
_RESPONSE_POLLING_TARGET: Final = "response_polling"
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])
_JSON_LIST: Final = TypeAdapter(list[object])
@@ -116,6 +119,7 @@ class ResponsePollingHandler:
parallel_tool_calls: bool | None = None,
user: str | None = None,
store: bool | None = None,
+ timing: Mapping[str, float] | None = None,
) -> None:
"""
Update the polling state in Redis
@@ -144,6 +148,7 @@ class ResponsePollingHandler:
parallel_tool_calls: Whether parallel tool calls are enabled
user: User identifier
store: Whether to store the response
+ timing: Server execution timings in milliseconds
"""
if not self.redis_cache:
return
@@ -167,6 +172,9 @@ class ResponsePollingHandler:
if output is not None:
state["output"] = output
+ if timing is not None:
+ state["litellm_response_timing"] = timing
+
# Update usage
if usage:
state["usage"] = usage
@@ -264,10 +272,10 @@ class ResponsePollingHandler:
def should_use_polling_for_request(
background_mode: bool,
- polling_via_cache_enabled, # Can be False, "all", or List[str]
- redis_cache, # RedisCache or None
+ polling_via_cache_enabled: bool | str | Sequence[str],
+ redis_cache: RedisCache | None,
model: str,
- llm_router, # Router instance or None
+ llm_router: "Router | None",
native_background_mode: list[str] | None = None, # List of models that should use native background mode
) -> bool:
"""
diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py
index a17b7fee8c9..ecc31f3b0a7 100644
--- a/litellm/proxy/route_llm_request.py
+++ b/litellm/proxy/route_llm_request.py
@@ -18,7 +18,9 @@ from litellm.litellm_core_utils.core_helpers import normalize_drop_params
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.routing_common_utils import is_proxy_admin_request
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
+from litellm.router import router_arguments
from litellm.router_api import router_init_args
+from litellm.router_config import build_router_config
# Client-supplied params that make the router or the call path fabricate a
# failure or a delay instead of calling the provider. The ``mock_testing_*``
@@ -98,7 +100,9 @@ async def _route_user_config_request(data: dict, route_type: str):
filtered_config: Final = {k: v for k, v in router_config.items() if k in valid_args}
logging_obj: Final = _request_logging_obj(JSON_OBJECT_ADAPTER.validate_python(data), route_type)
- cached_router: Final = user_config_routers.get(filtered_config, lambda: litellm.Router(**filtered_config))
+ cached_router: Final = user_config_routers.get(
+ filtered_config, lambda: litellm.Router.from_config(build_router_config(router_arguments(**filtered_config)))
+ )
cached_router.observe(logging_obj)
return await getattr(cached_router, f"{route_type}")(**{**data, "litellm_logging_obj": logging_obj})
diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
index 0efd4e4c619..e5348684d78 100644
--- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
+++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
@@ -216,6 +216,20 @@ class _SettingsWithSchema(LiteLLMBaseModel):
field_schema: dict[str, object]
+class BackgroundChatSettings(LiteLLMBaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ enabled: bool = Field(description="Enable cache-backed background Responses for Chat and API requests.")
+
+
+class BackgroundChatSettingsResponse(BackgroundChatSettings):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ redis_configured: bool = Field(description="Whether this proxy has coordination Redis configured.")
+ managed_by_config: bool = Field(description="Whether the responses configuration is owned by the config file.")
+ can_save: bool = Field(description="Whether database-backed configuration updates are available.")
+
+
class SSOSettingsResponse(SettingsResponse):
"""Response model for SSO settings"""
@@ -1815,6 +1829,87 @@ async def sync_ui_settings_to_general_settings(
return apply_runtime_general_settings_flags(parsed)
+def _background_chat_settings_response(config: Mapping[str, object]) -> BackgroundChatSettingsResponse:
+ from litellm.proxy import proxy_server
+
+ settings: Final = _as_settings_section(config.get("litellm_settings"))
+ responses: Final = _as_settings_section(settings.get("responses"))
+ background: Final = _as_settings_section(responses.get("background_mode"))
+ polling: Final = background.get("polling_via_cache", False)
+ return BackgroundChatSettingsResponse(
+ enabled=bool(polling),
+ redis_configured=proxy_server.redis_usage_cache is not None,
+ managed_by_config=proxy_server.proxy_config.litellm_settings.owned_by_config("responses"),
+ can_save=proxy_server.prisma_client is not None and proxy_server.store_model_in_db is True,
+ )
+
+
+@router.get(
+ "/get/background_chat_settings",
+ tags=["Settings"],
+ dependencies=[Depends(user_api_key_auth)],
+ response_model=BackgroundChatSettingsResponse,
+)
+async def get_background_chat_settings() -> BackgroundChatSettingsResponse:
+ """Get saved background-response settings and the prerequisites for changing them."""
+ from litellm.proxy.proxy_server import proxy_config
+
+ config: Final = _as_settings_section(_DECODED_JSON.validate_python(await proxy_config.get_config()))
+ return _background_chat_settings_response(config)
+
+
+@router.patch(
+ "/update/background_chat_settings",
+ tags=["Settings"],
+ dependencies=[Depends(user_api_key_auth)],
+ response_model=BackgroundChatSettingsResponse,
+)
+async def update_background_chat_settings(
+ settings: BackgroundChatSettings,
+ user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
+) -> BackgroundChatSettingsResponse:
+ """Save background-response polling settings for the proxy configuration refresh."""
+ from litellm.proxy.proxy_server import (
+ create_config_audit_log,
+ prisma_client,
+ proxy_config,
+ redis_usage_cache,
+ store_model_in_db,
+ )
+
+ if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
+ raise HTTPException(status_code=403, detail="Only proxy admins can update background chat settings.")
+ if prisma_client is None or store_model_in_db is not True:
+ raise HTTPException(status_code=400, detail="Enable database-backed configuration to save this setting.")
+ if settings.enabled and redis_usage_cache is None:
+ raise HTTPException(status_code=400, detail="Configure coordination Redis and restart the proxy first.")
+
+ raw_config: Final = await proxy_config.get_config()
+ config: Final = _as_settings_section(_DECODED_JSON.validate_python(raw_config))
+ litellm_settings: Final = _as_settings_section(config.get("litellm_settings"))
+ responses: Final = _UI_SETTINGS_OBJECT.validate_python(_as_settings_section(litellm_settings.get("responses")))
+ background: Final = _UI_SETTINGS_OBJECT.validate_python(_as_settings_section(responses.get("background_mode")))
+ updated_responses: Final[Mapping[str, JsonValue]] = {
+ **responses,
+ "background_mode": {**background, "polling_via_cache": "all" if settings.enabled else False},
+ }
+ proxy_config.reject_config_owned_writes(
+ section_name="litellm_settings", changed_keys={"responses": dict(updated_responses)}
+ )
+ raw_config["litellm_settings"] = {**litellm_settings, "responses": dict(updated_responses)}
+ await proxy_config.save_config(new_config=config)
+ asyncio.create_task(
+ create_config_audit_log(
+ param_name="responses.background_mode.polling_via_cache",
+ action="updated",
+ before_value=background.get("polling_via_cache", False),
+ after_value="all" if settings.enabled else False,
+ user_api_key_dict=user_api_key_dict,
+ )
+ )
+ return _background_chat_settings_response(config)
+
+
@router.get(
"/get/ui_settings",
tags=["UI Settings"],
diff --git a/litellm/responses/litellm_completion_transformation/count_request.py b/litellm/responses/litellm_completion_transformation/count_request.py
index a24a26aac42..0b769326012 100644
--- a/litellm/responses/litellm_completion_transformation/count_request.py
+++ b/litellm/responses/litellm_completion_transformation/count_request.py
@@ -128,6 +128,15 @@ def _with_hoisted_additional_tools(responses_request: Mapping[str, object], drop
return {**responses_request, "input": hoisted.input, "tools": list(hoisted.tools)}
+def _caller_input(responses_request: Mapping[str, object]) -> ResponseInputParam | None:
+ responses_input: Final = responses_request.get("input")
+ return (
+ cast(ResponseInputParam, responses_input) # cast-ok: forwarded as sent, as inference does
+ if isinstance(responses_input, list)
+ else None
+ )
+
+
def _with_emulated_file_search(responses_request: Mapping[str, object]) -> Mapping[str, object]:
tools: Final = responses_request.get("tools")
if tools is None:
@@ -200,6 +209,7 @@ async def chat_count_request(
),
custom_llm_provider,
policy.drop_params,
+ caller_input=_caller_input(responses_request),
),
model,
custom_llm_provider,
diff --git a/litellm/responses/litellm_completion_transformation/handler.py b/litellm/responses/litellm_completion_transformation/handler.py
index 04fe027cbcc..802ed6bf9c5 100644
--- a/litellm/responses/litellm_completion_transformation/handler.py
+++ b/litellm/responses/litellm_completion_transformation/handler.py
@@ -4,6 +4,7 @@ Handler for transforming responses api requests to litellm.completion requests
from collections.abc import Coroutine, Mapping, Sequence
from dataclasses import dataclass
+from types import MappingProxyType
from typing import Final
from pydantic import TypeAdapter
@@ -32,6 +33,13 @@ from litellm.types.llms.openai import (
from litellm.types.utils import ModelResponse
_REQUEST_KWARGS_ADAPTER: Final = TypeAdapter(dict[str, object])
+_BRIDGE_TRANSFORM_KEYWORDS: Final = frozenset({"caller_input", "names_non_string_fields"})
+
+
+def _without_bridge_transform_keywords(request_kwargs: Mapping[str, object]) -> Mapping[str, object]:
+ return MappingProxyType(
+ {key: value for key, value in request_kwargs.items() if key not in _BRIDGE_TRANSFORM_KEYWORDS}
+ )
def _chat_verbosity(responses_api_request: Mapping[str, object]) -> Mapping[str, object]:
@@ -95,6 +103,7 @@ class LiteLLMCompletionTransformationHandler:
previous_response_id,
hoisted,
model=model,
+ caller_input=input,
responses_api_request=responses_api_request,
custom_llm_provider=custom_llm_provider,
stream=stream,
@@ -108,11 +117,13 @@ class LiteLLMCompletionTransformationHandler:
**LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model=model,
input=bridged_input,
+ caller_input=input,
+ names_non_string_fields=True,
responses_api_request=bridged_request,
custom_llm_provider=custom_llm_provider,
stream=stream,
extra_headers=extra_headers,
- **kwargs,
+ **_without_bridge_transform_keywords(_REQUEST_KWARGS_ADAPTER.validate_python(kwargs)),
),
**_chat_verbosity(bridged_request),
}
@@ -163,6 +174,7 @@ class LiteLLMCompletionTransformationHandler:
/,
*,
model: str,
+ caller_input: str | ResponseInputParam,
responses_api_request: ResponsesAPIOptionalRequestParams,
custom_llm_provider: str | None,
stream: bool | None,
@@ -186,11 +198,13 @@ class LiteLLMCompletionTransformationHandler:
**LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model=model,
input=bridged.input,
+ caller_input=caller_input,
+ names_non_string_fields=True,
responses_api_request=bridged.responses_api_request,
custom_llm_provider=custom_llm_provider,
stream=stream,
extra_headers=extra_headers,
- **request_kwargs,
+ **_without_bridge_transform_keywords(request_kwargs),
),
**_chat_verbosity(bridged.responses_api_request),
},
diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py
index 17ae9f2e656..0f4ed0611be 100644
--- a/litellm/responses/litellm_completion_transformation/transformation.py
+++ b/litellm/responses/litellm_completion_transformation/transformation.py
@@ -45,6 +45,7 @@ from litellm.constants import (
REDACTED_BY_LITELLM,
REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER,
)
+from litellm.exceptions import BadRequestError
from litellm.integrations.anthropic_cache_control_hook import (
AnthropicCacheControlHook,
block_with_cache_control,
@@ -65,6 +66,12 @@ from litellm.litellm_core_utils.hidden_params import (
)
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.model_param_helper import embeds_thought_signature_in_tool_call_id
+from litellm.litellm_core_utils.prompt_templates.common_utils import (
+ invalid_type_message,
+ is_present_non_string,
+ is_unhashable_value_behind,
+)
+from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import message_field, parts_of
from litellm.llms.anthropic.common_utils import supports_anthropic_cache_control
from litellm.llms.anthropic.messages.native.utils import anthropic_stop_reason, openai_chat_refusal_text
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
@@ -268,6 +275,7 @@ _IMAGE_URL_ADAPTER: Final = TypeAdapter(ChatCompletionImageUrlObject)
_FILE_PART_ADAPTER: Final = TypeAdapter(_ResponsesFilePart)
_MessageT = TypeVar("_MessageT")
_MediaT: Final = TypeVar("_MediaT")
+_InputItemT: Final = TypeVar("_InputItemT")
_CACHED_CONTENT_ADAPTER: Final = TypeAdapter(ChatCompletionCachedContent)
_DICT_ITEMS_LIST_ADAPTER: Final = TypeAdapter(list[dict[object, object]])
_TEXT_ADAPTER: Final = TypeAdapter(str)
@@ -445,6 +453,86 @@ TOOL_CALL_OUTPUT_ITEM_TYPES: Final = frozenset(
)
+def _read_positions(
+ input_items: Sequence[_InputItemT], read_input: str | ResponseInputParam
+) -> Iterator[tuple[int, _InputItemT]]:
+ read_ids: Final = frozenset(map(id, parts_of(read_input)))
+ return ((index, item) for index, item in enumerate(input_items) if id(item) in read_ids)
+
+
+def _first_invalid_input_type(fields: Iterator[tuple[str, object]]) -> BadRequestError | None:
+ first: Final = next(fields, None)
+ return None if first is None else _invalid_input_type(*first)
+
+
+def _is_tool_output_item(item: object) -> bool:
+ item_type: Final = message_field(item, "type")
+ return isinstance(item_type, str) and item_type in TOOL_CALL_OUTPUT_ITEM_TYPES
+
+
+def _converts_before(
+ convert_input: Callable[[str | ResponseInputParam], object], sent_input: str | ResponseInputParam, position: int
+) -> bool:
+ try:
+ convert_input(sent_input[:position])
+ except TypeError:
+ return False
+ return True
+
+
+def _unhashable_tool_output_call_id_error(
+ input: str | ResponseInputParam,
+ read_input: str | ResponseInputParam,
+ error: TypeError,
+ converts_before: Callable[[int], bool],
+) -> BadRequestError | None:
+ call_ids: Final = (
+ (f"input[{index}].call_id", message_field(item, "call_id"), item)
+ for index, item in _read_positions(parts_of(input), read_input)
+ if _is_tool_output_item(item)
+ )
+ first: Final = next((candidate for candidate in call_ids if is_unhashable_value_behind(error, candidate[1])), None)
+ if first is None:
+ return None
+ path, call_id, item = first
+ read_position: Final = next(
+ position for position, read_item in enumerate(parts_of(read_input)) if read_item is item
+ )
+ return _invalid_input_type(path, call_id) if converts_before(read_position) else None
+
+
+def _carries_non_string_field(message: object) -> bool:
+ tool_call_ids: Final = (
+ message_field(tool_call, "id") for tool_call in parts_of(message_field(message, "tool_calls"))
+ )
+ return is_present_non_string(message_field(message, "role")) or any(map(is_present_non_string, tool_call_ids))
+
+
+def _non_string_item_fields(
+ item: Mapping[str, object], convert_item: Callable[[Mapping[str, object]], Sequence[object]]
+) -> Iterator[tuple[str, object]]:
+ messages: Final = convert_item(item)
+ tool_call_id_field: Final = "call_id" if item.get("call_id") else "id"
+ roles: Final = (("role", message_field(message, "role")) for message in messages)
+ tool_calls: Final = chain.from_iterable(parts_of(message_field(message, "tool_calls")) for message in messages)
+ tool_call_ids: Final = ((tool_call_id_field, message_field(tool_call, "id")) for tool_call in tool_calls)
+ return ((field, value) for field, value in chain(roles, tool_call_ids) if is_present_non_string(value))
+
+
+def _indexed_non_string_item_fields(
+ input: ResponseInputParam,
+ read_input: str | ResponseInputParam,
+ convert_item: Callable[[Mapping[str, object]], Sequence[object]],
+) -> Iterator[tuple[str, object]]:
+ for index, item in _read_positions(input, read_input):
+ for field, value in _non_string_item_fields(item, convert_item):
+ yield f"input[{index}].{field}", value
+
+
+def _invalid_input_type(field_path: str, value: object) -> BadRequestError:
+ return BadRequestError(message=invalid_type_message(field_path, value), model="", llm_provider="")
+
+
def _input_item_role(input_item: Mapping[str, object]) -> str:
return cast(str, input_item.get("role") or "user") # cast-ok: client-supplied role forwarded verbatim, unvalidated
@@ -1101,6 +1189,9 @@ class LiteLLMCompletionResponsesConfig:
stream: bool | None = None,
extra_headers: Mapping[str, object] | None = None,
drop_params: object = None,
+ *,
+ caller_input: str | ResponseInputParam | None = None,
+ names_non_string_fields: bool = False,
**kwargs,
) -> dict:
"""
@@ -1149,6 +1240,7 @@ class LiteLLMCompletionResponsesConfig:
native_compaction: Final = _replays_compaction_block(
request_input, context_management
) and provider_takes_compaction_blocks(model, custom_llm_provider)
+ numbered_input: Final = request_input if caller_input is None else caller_input
bridged_messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=request_input,
responses_api_request=responses_api_request,
@@ -1157,7 +1249,28 @@ class LiteLLMCompletionResponsesConfig:
tool_call_provider_fields=not embeds_thought_signature_in_tool_call_id(request_kwargs),
native_compaction=native_compaction,
only_since_compaction=not native_compaction,
+ caller_input=numbered_input,
)
+ malformed_field_error: Final = (
+ _first_invalid_input_type(
+ _indexed_non_string_item_fields(
+ numbered_input,
+ _input_to_read(request_input, not native_compaction, native_compaction),
+ partial(
+ LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message,
+ replay_reasoning=True,
+ native_compaction=native_compaction,
+ ),
+ )
+ )
+ if names_non_string_fields
+ and isinstance(numbered_input, list)
+ and any(map(_carries_non_string_field, bridged_messages))
+ and provider_builds_anthropic_request(model, custom_llm_provider)
+ else None
+ )
+ if malformed_field_error is not None:
+ raise malformed_field_error
litellm_completion_request: dict = {
"messages": (
@@ -1221,6 +1334,7 @@ class LiteLLMCompletionResponsesConfig:
tool_call_provider_fields: bool = False,
native_compaction: bool = False,
only_since_compaction: bool = False,
+ caller_input: str | ResponseInputParam | None = None,
) -> list[
AllMessageValues
| GenericChatCompletionMessage
@@ -1274,13 +1388,27 @@ class LiteLLMCompletionResponsesConfig:
dumped_input: Final = with_compaction_items_as_params(input)
read_input: Final = _input_to_read(dumped_input, only_since_compaction, native_compaction)
- messages.extend(
- LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
- input=_without_item_keys(read_input, _dropped_item_keys(item_cache_control, tool_call_provider_fields)),
- replay_reasoning=replay_reasoning,
- native_compaction=native_compaction,
- )
+ sent_input: Final = _without_item_keys(
+ read_input, _dropped_item_keys(item_cache_control, tool_call_provider_fields)
)
+ convert_input: Final = partial(
+ LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message,
+ replay_reasoning=replay_reasoning,
+ native_compaction=native_compaction,
+ )
+ try:
+ converted: Final = convert_input(sent_input)
+ except TypeError as error:
+ call_id_error: Final = _unhashable_tool_output_call_id_error(
+ dumped_input if caller_input is None else caller_input,
+ read_input,
+ error,
+ partial(_converts_before, convert_input, sent_input),
+ )
+ if call_id_error is None:
+ raise
+ raise call_id_error from error
+ messages.extend(converted)
return messages
diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py
index c32a11797eb..09ee6828157 100644
--- a/litellm/responses/streaming_iterator.py
+++ b/litellm/responses/streaming_iterator.py
@@ -233,6 +233,11 @@ def spawn_logging_task(
async def drain_logging_tasks(timeout: float = 5.0) -> None:
+ # A task that finished as its loop closed never ran its discard callback, and a
+ # task from another loop cannot be awaited here: forget both, wait on the rest.
+ running_loop: Final = asyncio.get_running_loop()
+ stale: Final = {task for task in _PENDING_LOGGING_TASKS if task.done() or task.get_loop() is not running_loop}
+ _PENDING_LOGGING_TASKS.difference_update(stale)
if not _PENDING_LOGGING_TASKS:
return
_, pending = await asyncio.wait(tuple(_PENDING_LOGGING_TASKS), timeout=timeout)
diff --git a/litellm/router.py b/litellm/router.py
index b648dbaea8f..4ee138fc9fb 100644
--- a/litellm/router.py
+++ b/litellm/router.py
@@ -1,17605 +1,330 @@
-# +-----------------------------------------------+
-# | |
-# | Give Feedback / Get Help |
-# | https://github.com/BerriAI/litellm/issues/new |
-# | |
-# +-----------------------------------------------+
-#
-# Thank you ! We ❤️ you! - Krrish & Ishaan
-
"""
litellm.Router: load balancing, retries, fallbacks and cooldowns across deployments.
-Public surface. The router is moving behind a narrow interface; code outside
-the router should end up using a Router through two kinds of members only:
-
-- endpoint methods (acompletion, aembedding, aresponses, aanthropic_messages,
- the files, batches, assistants and vector store wrappers, ...), each a thin
- wrapper around one call;
-- entry points (being added) that replace direct access to internals: registry() for
- deployment lookups, settings() for configuration reads, health() for
- cooldowns, blocks and usage, select() to pick a deployment without calling
- it, apply() for runtime changes, observe() for routing events,
- register_plugin() for strategies and sub-routers, and retire() to release
- the router.
-
-Everything else on the class is internal. The members outside code uses today
-are listed in tests/code_coverage_tests/router_surface_allowlist.json, and a CI
-check (router_surface_check.py next to it) fails on any new outside use, so the
-list only shrinks while callers move to the entry points. The same interface
-is documented for the Rust core in litellm-rust/crates/router-core.
+``Router`` holds no routing logic. It builds its configuration (``litellm/router_config.py``), picks
+one ``RouterCore`` at construction (``litellm/router_core.py``) and delegates every member to it:
+the endpoint methods (acompletion, aembedding, aresponses, aanthropic_messages, ...) and the entry
+points (registry, settings, health, select, apply, observe, register_plugin, retire). The members
+outside code used before the entry points live in ``DeprecatedRouterMembers`` until they are removed.
"""
-import asyncio
-import contextlib
-import copy
-import enum
import inspect
-import json
-import logging
-import re
-import threading
-import time
-import traceback
import weakref
-from collections import defaultdict
-from collections.abc import (
- AsyncGenerator,
- AsyncIterable,
- AsyncIterator,
- Awaitable,
- Callable,
- Generator,
- Iterable,
- Iterator,
- Mapping,
- MutableMapping,
- Sequence,
-)
-from contextvars import ContextVar
-from dataclasses import dataclass
-from datetime import datetime, timezone
-from functools import lru_cache, partial
-from itertools import chain
-from types import MappingProxyType
-from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NoReturn, Optional, TypeAlias, TypeVar, Union, cast
+from collections.abc import Mapping
+from types import CellType, FunctionType, MappingProxyType
+from typing import TYPE_CHECKING, Final
-import anyio
-import httpx
-import openai
-from openai import AsyncOpenAI
-from pydantic import BaseModel, TypeAdapter, ValidationError
-from typing_extensions import assert_never, overload
-
-import litellm
-import litellm.litellm_core_utils.exception_mapping_utils
-from litellm import get_secret_str
-from litellm._internal_context import in_mirrored_call, mirrored_call, service_target, with_service_target
-from litellm._logging import verbose_router_logger
-from litellm._uuid import uuid
-from litellm.caching.caching import (
- DualCache,
- InMemoryCache,
- RedisCache,
- RedisClusterCache,
-)
-from litellm.caching.redis_cache import log_redis_failure
-from litellm.constants import (
- BAGGAGE_MAPPED_TAGS_METADATA_KEY,
- CLIENT_OUTPUT_CEILING_METADATA_KEY,
- CONSUMED_REQUEST_TAGS_METADATA_KEY,
- DEFAULT_AUTO_ROUTER_MAX_INPUT_CHARS,
- DEFAULT_HEALTH_CHECK_INTERVAL,
- DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER,
- DEFAULT_MAX_LRU_CACHE_SIZE,
- INTERNAL_CALL_ORIGIN_METADATA_KEY,
- KUBERNETES_POD_ROUTING_KEY,
- MID_STREAM_FAILURE_LOG_WAIT_SECONDS,
- OUTPUT_TOKEN_CEILING_PARAMS,
- PROMPT_CACHE_PIN_CACHE_MAX_SIZE,
- ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY,
- ROUTING_REQUEST_TAGS_METADATA_KEY,
- RUNTIME_UPDATABLE_ROUTER_SETTINGS,
- SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
-)
-from litellm.integrations.custom_guardrail import CustomGuardrail, is_guardrail_intervention
-from litellm.integrations.custom_logger import CustomLogger
-from litellm.integrations.otel.routing import routing_decision_attributes
-from litellm.integrations.otel.runtime import phase_attributes, phase_event, phase_span
-from litellm.litellm_core_utils.access_windows import access_windows_config_error, filter_reserved_deployments
-from litellm.litellm_core_utils.add_retry_fallback_headers import (
- add_fallback_headers_to_response,
- add_retry_headers_to_response,
- apply_quality_router_decision_headers,
- apply_remaining_usage_headers,
- apply_response_model_id,
- complexity_router_decision_headers,
- ensure_response_additional_headers,
- get_hidden_params_dict,
- prepare_response_for_header_attachment,
- replace_complexity_router_headers,
- response_total_token_count,
-)
-from litellm.litellm_core_utils.asyncify import run_async_function
-from litellm.litellm_core_utils.batch_utils import (
- batch_create_routing,
- deployment_supported_endpoints,
- get_router_metadata_variable_name,
- is_batch_retrieve_call_type,
- replace_model_in_jsonl,
- should_replace_model_in_jsonl,
- without_deployments_that_cannot_batch,
-)
-from litellm.litellm_core_utils.clientside_credential_handler import (
- get_dynamic_litellm_params,
- is_clientside_credential,
-)
-from litellm.litellm_core_utils.completion_timeout import CompletionTimeout
-from litellm.litellm_core_utils.core_helpers import (
- coerce_token_limit,
- get_litellm_metadata_from_kwargs,
- get_metadata_variable_name_from_kwargs,
- get_or_create_metadata_bucket,
- get_parent_otel_span_from_kwargs,
-)
-from litellm.litellm_core_utils.coroutine_checker import coroutine_checker
-from litellm.litellm_core_utils.credential_accessor import CredentialAccessor, reject_api_key_secret_reference
-from litellm.litellm_core_utils.dd_tracing import tracer
-from litellm.litellm_core_utils.deployment_pricing import deployment_pricing_models
-from litellm.litellm_core_utils.get_litellm_params import SUPPLEMENTAL_KWARGS_KEYS
-from litellm.litellm_core_utils.get_llm_provider_logic import (
- declared_authenticating_provider,
- inferred_provider,
- is_registered_custom_provider,
- resolve_model_and_provider_for_metadata,
-)
-from litellm.litellm_core_utils.get_retry_from_policy import (
- get_num_retries_from_retry_policy as _get_num_retries_from_retry_policy,
-)
-from litellm.litellm_core_utils.get_retry_from_policy import resolve_retry_policy
-from litellm.litellm_core_utils.hidden_params import (
- HIDDEN_PARAMS_ATTR as _HIDDEN_PARAMS_ATTR,
-)
-from litellm.litellm_core_utils.hidden_params import (
- get_hidden_params,
- set_hidden_params,
-)
-from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
-from litellm.litellm_core_utils.llm_cost_calc.utils import SERVICE_TIER_COST_KEY_SUFFIXES
-from litellm.litellm_core_utils.pattern_match_deployments import PatternMatchRouter
-from litellm.litellm_core_utils.ptu_pricing import (
- PTU_COST_ATTRIBUTION_ENV_VAR,
- declares_ptu,
- declares_ptu_shares,
- is_ptu_cost_attribution_enabled,
- ptu_config_error,
- ptu_identity_error,
- ptu_terms,
- zeroed_ptu_pricing,
-)
-from litellm.litellm_core_utils.ptu_shares import (
- PTUTeamCeiling,
- filter_ptu_shared_deployments,
- ptu_capacity_warning,
- ptu_share_teams,
- share_holder_request_kwargs,
- team_ptu_ceiling,
-)
-from litellm.litellm_core_utils.reasoning_effort_capability import (
- deployment_is_catalog_mapped,
- intersect_supported_reasoning_efforts,
- resolve_supported_reasoning_efforts,
-)
-from litellm.litellm_core_utils.request_timeout_resolver import (
- get_configured_request_timeout,
- get_service_tier_timeout,
-)
-from litellm.litellm_core_utils.routing_common_utils import (
- MID_STREAM_CONTINUATION_KWARG,
- as_retry_skipped_deployment_ids,
- deployment_model_id_pin,
- deployments_the_caller_may_use,
- filter_team_based_models,
- filter_web_search_deployments,
- format_content_policy_fallback_hint,
- format_content_policy_no_fallback_note,
- format_fallback_outcome_message,
- format_no_fallback_group_message,
- get_request_team_id,
- is_proxy_admin_request,
- provider_for_generic_call,
- request_user_api_key_auth,
- resolve_model_group_alias,
- team_may_use_deployment,
- truncate_fallback_error_detail,
- warn_on_ignored_model_capability_flags,
- warn_on_provider_credential_mismatch,
- with_admin_api_key_secret_reference,
- without_model_capability_flags,
- without_router_only_kwargs,
-)
-from litellm.litellm_core_utils.routing_groups import (
- apply_routing_group_priority,
- parse_routing_groups,
- validate_routing_strategy,
-)
-from litellm.litellm_core_utils.secret_redaction import redact_string
-from litellm.litellm_core_utils.sensitive_data_masker import (
- SensitiveDataMasker,
- mask_sensitive_structure,
-)
-from litellm.litellm_core_utils.token_counter import (
- offload_token_count,
- served_openai_models,
- single_encoding_openai_model,
-)
-from litellm.litellm_core_utils.tool_fields import TOOL_FIELDS, drop_policy
-from litellm.llms.anthropic.common_utils import AnthropicModelInfo, anthropic_error_frame_exception
-from litellm.llms.base_llm.passthrough.transformation import replace_path_segment
-from litellm.llms.base_llm.vector_store.transformation import (
- RouterVectorStoreEmbeddingExecutor,
- vector_store_request_metadata,
-)
-from litellm.llms.bedrock.common_utils import get_bedrock_base_model
-from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_async_httpx_client
-from litellm.llms.openai_like.json_loader import JSONProviderRegistry
-from litellm.llms.openai_like.model_info import (
- MODEL_INFO_DISCOVERY_PROVIDERS,
- MODEL_INFO_REFRESH_CONCURRENCY,
- MODEL_INFO_REFRESH_SECONDS,
- get_openai_compatible_model_info,
-)
-from litellm.responses.bridge_check import keep_registered_mode
-from litellm.responses.retrieve_stream_query import requests_streamed_retrieval, without_retrieve_stream_defaults
-from litellm.router_api import (
- SETTING_FIELDS,
- ApplyResult,
- ChangeSet,
- RouterHealth,
- RouterRegistry,
- RouterSettings,
- RoutingObservers,
- SelectKind,
- aapply_changes,
- apply_changes,
-)
-from litellm.router_api import RoutingContext as RouterContext
-from litellm.router_api import deployment_id as _deployment_id
+from litellm import router_python_core
+from litellm.router_config import RouterConfig, build_router_config
+from litellm.router_core import RouterCore, make_core
from litellm.router_deprecated import DeprecatedRouterMembers
-from litellm.router_host import router_host
-from litellm.router_strategy.auto_router.auto_router_model_naming import (
- AUTO_ROUTER_MODEL_PREFIX,
- STRATEGY_ROUTER_PARAM_FIELDS,
- GatedAutoRouterCapability,
- capability_limit_violation,
- claimed_capabilities,
- classify_strategy_router_model,
- count_capability_routers,
-)
-from litellm.router_strategy.base_routing_strategy import BaseRoutingStrategy
-from litellm.router_strategy.budget_limiter import RouterBudgetLimiting
-from litellm.router_strategy.complexity_router.context_compaction import (
- arm_compaction,
- compact_to_fit,
- compaction_pending,
- initialize_compaction_state,
- is_native_compaction_call,
- reject_recursive_compactor,
- surface_for_call,
-)
-from litellm.router_strategy.least_busy import LeastBusyLoggingHandler
-from litellm.router_strategy.lowest_cost import LowestCostLoggingHandler
-from litellm.router_strategy.lowest_latency import LowestLatencyLoggingHandler
-from litellm.router_strategy.lowest_tpm_rpm import LowestTPMLoggingHandler
-from litellm.router_strategy.lowest_tpm_rpm_v2 import LowestTPMLoggingHandler_v2, PrefetchedUsage
-from litellm.router_strategy.simple_shuffle import simple_shuffle
-from litellm.router_strategy.tag_based_routing import (
- baggage_stamp_for_serving_deployment,
- get_deployments_for_tag,
- get_routing_tags_from_request_kwargs,
- get_tags_from_request_kwargs,
- is_valid_deployment_tag,
- tags_with_serving_deployment_tags,
-)
-from litellm.router_utils.client_initalization_utils import InitalizeCachedClient, MaxParallelRequestsLimit
-from litellm.router_utils.cooldown_cache import CooldownCache
-from litellm.router_utils.cooldown_handlers import (
- DEFAULT_COOLDOWN_TIME_SECONDS,
- _first_present, # pyright: ignore[reportPrivateUsage] - shared internal helper across router_utils submodules, matching the other cooldown_handlers imports on this line
- async_get_cooldown_deployments,
- async_get_cooldown_deployments_with_debug_info,
- get_cooldown_deployment_info,
- get_cooldown_deployments,
- is_background_response_poll_not_found,
- is_caller_scoped_auth_failure,
- is_caller_timeout_408,
- is_failure_outside_selected_deployment,
- is_video_lookup_not_found,
- mark_deployment_selection_failure,
- set_cooldown_deployments,
-)
-from litellm.router_utils.embedding_batch_size_check import (
- async_log_rejection_before_provider_call,
- clear_rejected_before_provider_call,
- deployments_admitting_embedding_batch,
- deployments_when_none_admits_embedding_batch,
- embedding_batch_too_large_error,
- embedding_deployment_selection,
- log_rejection_before_provider_call,
-)
-from litellm.router_utils.endpoint_eligibility import (
- eligible_deployments,
- endpoint_selection,
- warn_on_unenforced_endpoint_flag,
-)
-from litellm.router_utils.fallback_event_handlers import (
- MID_STREAM_FALLBACK_CONTROLS_KEY,
- AttemptedFallbackTargets,
- attempted_fallback_targets_from,
- attempted_retries_for_request,
- carry_over_pre_routing_selection,
- carry_over_routed_deployment,
- carry_superseded_attempt_usage,
- check_non_standard_fallback_format,
- clear_pre_routing_selection,
- committed_retry_budget_for_request,
- continuation_fallback_targets,
- fallback_lookup_groups,
- fallback_target_name,
- fallback_target_overrides_request_params,
- fallbacks_disabled_for_request,
- get_fallback_model_group_for_lookup_groups,
- get_pre_routing_selection,
- has_unattempted_fallback_target,
- is_bad_request_without_fallback,
- mid_stream_fallback_hop_kwargs,
- mid_stream_fallback_snapshot_kwargs,
- mid_stream_retry_kwargs,
- per_request_fallback_controls,
- record_disable_fallbacks,
- record_pre_routing_selection,
- record_retry_attempt,
- routed_deployment_id,
- run_async_fallback,
-)
-from litellm.router_utils.handle_error import (
- async_raise_no_deployment_exception,
- send_llm_exception_alert,
-)
-from litellm.router_utils.health_state_cache import DeploymentHealthCache
-from litellm.router_utils.kubernetes_pod_discovery import (
- KubernetesPodDiscovery,
- async_resolve_pods_after,
- async_resolve_pods_after_bound,
- resolve_pods_after,
- resolve_pods_after_bound,
-)
-from litellm.router_utils.pre_call_checks.continuation_prefill_check import (
- MID_STREAM_CONTINUATION_MARKER,
- RESPONSES_CONTINUATION_KWARG,
- ContinuationOutput,
- ContinuationPrefillDeploymentCheck,
- ContinuedPartial,
- ResponsesContinuation,
- ResponsesContinuationSeam,
- constrains_continuation,
- constrains_responses_continuation,
- continuation_deployments,
- continuation_output_ceilings,
- continued_text,
- deployment_can_continue,
- responses_continuation_input,
- responses_continuation_input_for,
-)
-from litellm.router_utils.pre_call_checks.deployment_affinity_check import (
- DeploymentAffinityCheck,
- warn_on_unknown_model_group_affinity_flags,
-)
-from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import (
- EncryptedContentAffinityCheck,
-)
-from litellm.router_utils.pre_call_checks.io_token_rate_limit_check import (
- async_admit_io_token_turn,
- async_io_token_refund_failure,
- build_io_token_rate_limit_headers,
- deployment_has_io_token_limits,
- refund_stale_reservation_before_retry,
- set_io_token_rate_limit_request_kwargs,
- without_io_token_limits,
-)
-from litellm.router_utils.pre_call_checks.model_rate_limit_check import (
- ModelRateLimitingCheck,
-)
-from litellm.router_utils.pre_call_checks.prompt_caching_deployment_check import (
- PromptCachingDeploymentCheck,
-)
-from litellm.router_utils.request_priority import (
- InvalidPriority,
- parse_default_priority,
- priority_error,
- priority_header_fields,
- request_drops_params,
- resolve_request_priority,
- unqueueable_priority_message,
-)
-from litellm.router_utils.router_callbacks.track_deployment_metrics import (
- find_deployment_metadata,
- get_counted_usage_tokens,
- increment_deployment_failures_for_current_minute,
- increment_deployment_successes_for_current_minute,
-)
-from litellm.router_utils.routing_read_batch import ROUTER_USAGE_TARGET, RoutingPrefetch, RoutingReadBatch
-from litellm.router_utils.stream_usage_injection import router_config_requests_stream_usage
-from litellm.scheduler import FlowItem, Scheduler
-from litellm.secret_managers.main import str_to_bool
-from litellm.types.guardrails import GuardrailEventHooks
-from litellm.types.litellm_params import STREAM_USAGE_INJECTED_FIELD, RoutingStrategyName
-from litellm.types.llms.openai import (
- AllMessageValues,
- ChatCompletionToolParam,
- FileTypes,
- OpenAIFileObject,
- OpenAIFilesPurpose,
+from litellm.router_python_core import (
+ PythonCore,
+ as_output_cap,
+ model_info_is_active_for_environment,
+ stamped_client_ceiling,
)
+from litellm.router_python_host import PythonHost, python_host
from litellm.types.router import (
- CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS,
- VALID_LITELLM_ENVIRONMENTS,
- AlertingConfig,
AllowedFailsPolicy,
AssistantsTypedDict,
- AutoRouterCapabilityLimit,
- ConsumedRequestTagsStamp,
- CredentialLiteLLMParams,
CustomRoutingStrategyBase,
Deployment,
- DeploymentModelListingInfo,
- DeploymentTypedDict,
- DiscoveredDeploymentModelInfo,
- FallbackAccessCheck,
- FallbackBudgetCheck,
- GenericLiteLLMParams,
- GuardrailTypedDict,
LiteLLM_Params,
- LiteLLMParamsTypedDict,
- MockRouterTestingParams,
ModelGroupInfo,
- OptionalPreCallChecks,
- PreRoutingStrategy,
RetryPolicy,
- RouterCacheEnum,
- RouterErrors,
- RouterGeneralSettings,
- RouterModelGroupAliasItem,
- RouterRateLimitError,
- RouterRateLimitErrorBasic,
- RoutingContext,
- RoutingGroup,
- RoutingPlugin,
- RoutingStrategy,
- SearchToolTypedDict,
- TaggedPreRoutingStrategy,
- holds_secret_pointer,
- validate_litellm_params_strictly,
-)
-from litellm.types.services import ServiceTypes
-from litellm.types.utils import (
- AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
- PROMPT_QUOTING_ROUTING_DECISION_FIELDS,
- CustomPricingLiteLLMParams,
- GenericBudgetConfigType,
- LiteLLMBatch,
- LlmProviders,
- ModelInfo,
- ModelResponseStream,
- StandardLoggingPayload,
- StandardLoggingRoutingDecision,
- Usage,
- all_litellm_params,
- is_server_derived_pricing_key,
- shared_backend_model_info,
-)
-from litellm.types.utils import ModelInfo as ModelMapInfo
-from litellm.types.videos.utils import VIDEO_ID_CALL_TYPES, decode_video_id_with_provider
-from litellm.utils import (
- CustomStreamWrapper,
- EmbeddingResponse,
- ModelInfoLookupMiss,
- ModelResponse,
- ProviderConfigManager,
- Rules,
- TextCompletionResponse,
- _restore_correlation_context_if_supported, # pyright: ignore[reportPrivateUsage] # same task-local cleanup as the SDK wrapper
- aresolve_api_key_secret_reference,
- function_setup,
- get_llm_provider,
- get_non_default_completion_params,
- get_secret,
- get_utc_datetime,
- is_generalized_model_info,
- is_region_allowed,
- lookup_model_info_for_registration,
- model_info_with_pricing_overrides,
- provider_rejectable_params,
- set_live_deployment_replay,
- token_counter,
)
+_CORE_SIGNATURE: Final = inspect.signature(PythonCore.__init__)
+
+
+def router_arguments(
+ *args: object,
+ **kwargs: object, # kwargs-ok: Router's constructor arguments, as passed
+) -> Mapping[str, object]:
+ """The constructor arguments as passed, by name (positional ones bound to their parameter)."""
+ try:
+ bound: Final = _CORE_SIGNATURE.bind_partial(None, *args, **kwargs).arguments
+ except TypeError as error:
+ raise TypeError(f"Router.__init__() {error}") from None
+ return MappingProxyType({name: value for name, value in bound.items() if name != "self"})
+
+
+def _class_descriptor(cls: type, name: str) -> bool:
+ return any(hasattr(vars(base).get(name), "__set__") for base in cls.__mro__ if name in vars(base))
+
+
if TYPE_CHECKING:
- from opentelemetry.trace import Span as _Span
- from litellm.exceptions import MidStreamFallbackError
- from litellm.responses.streaming_iterator import (
- BaseResponsesAPIStreamingIterator,
- )
- from litellm.router_api import UntypedDict
- from litellm.router_strategy.adaptive_router.adaptive_router import (
- AdaptiveRouter,
- )
- from litellm.router_strategy.auto_router.auto_router import (
- AutoRouter,
- PreRoutingHookResponse,
- )
- from litellm.router_strategy.complexity_router.complexity_router import (
- ComplexityRouter,
- )
- from litellm.router_strategy.quality_router.quality_router import (
- QualityRouter,
- )
- from litellm.types.llms.anthropic_messages.anthropic_response import (
- AnthropicMessagesResponse,
- )
- from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject
- from litellm.types.llms.openai import (
- ResponseAPIUsage,
- ResponseIncompleteEvent,
- ResponsesAPIResponse,
- )
+ class Router(PythonCore, DeprecatedRouterMembers):
+ @classmethod
+ def from_config(cls, config: RouterConfig, host: PythonHost = python_host) -> "Router": ...
- Span = _Span
else:
- Span = Any
- AutoRouter = Any
- ComplexityRouter = Any
- AdaptiveRouter = Any
- QualityRouter = Any
- PreRoutingHookResponse = Any
-
-RouterStrategySelector: TypeAlias = (
- LeastBusyLoggingHandler
- | LowestCostLoggingHandler
- | LowestLatencyLoggingHandler
- | LowestTPMLoggingHandler
- | LowestTPMLoggingHandler_v2
-)
-
-
-def _cost_value_as_float(value: str | float | None) -> float | None:
- if value is None:
- return None
- try:
- return float(value)
- except (TypeError, ValueError):
- return None
-
-
-def model_info_is_active_for_environment(model_info: Mapping[str, object] | None) -> bool:
- """Single owner of the environment-gating rule: a deployment whose model_info names
- `supported_environments` loads only on pods whose LITELLM_ENVIRONMENT is in that list.
- `Router.deployment_is_active_for_environment` delegates here, and the model-write
- endpoints consult the same rule to tell a deliberately inactive model from one that
- was dropped by a failed reload."""
- if model_info is None:
- return True
- supported_environments: Final = model_info.get("supported_environments")
- if supported_environments is None:
- return True
- if not isinstance(supported_environments, (list, tuple)):
- raise ValueError(
- f"supported_environments must be a list of {VALID_LITELLM_ENVIRONMENTS}. "
- f"but set as: {supported_environments} for model_info: {model_info}"
- )
- litellm_environment: Final = get_secret_str(secret_name="LITELLM_ENVIRONMENT")
- if litellm_environment is None:
- raise ValueError("Set 'supported_environments' for model but not 'LITELLM_ENVIRONMENT' set in .env")
-
- if litellm_environment not in VALID_LITELLM_ENVIRONMENTS:
- raise ValueError(
- f"LITELLM_ENVIRONMENT must be one of {VALID_LITELLM_ENVIRONMENTS}. but set as: {litellm_environment}"
- )
-
- for _env in supported_environments:
- if _env not in VALID_LITELLM_ENVIRONMENTS:
- raise ValueError(
- f"supported_environments must be one of {VALID_LITELLM_ENVIRONMENTS}. but set as: {_env} "
- f"for model_info: {model_info}"
- )
-
- if litellm_environment in supported_environments:
- return True
- return False
-
-
-_PreRoutingStrategyT = TypeVar("_PreRoutingStrategyT")
-_CallbackT = TypeVar("_CallbackT")
-_DeploymentT = TypeVar("_DeploymentT", bound=Mapping[str, object])
-
-
-_ALIAS_PARAMS_NEVER_FORWARDED: Final = frozenset(
- {"model", "api_base", "api_key", "api_version", "responses_api_version"}
-)
-_ALIAS_MARKER_FORWARDED_PARAMS_KWARG: Final = "_alias_marker_forwarded_params"
-_FORWARDED_CREDENTIAL_ISOLATION_PROVIDERS: Final = frozenset({"chatgpt", "anthropic"})
-_CLAUDE_CODE_SESSION_ID_RE: Final = re.compile(r"^[a-zA-Z0-9_\-]{8,}$")
-_CLAUDE_CODE_SESSION_ROUTER_TTL_SECONDS: Final = 3600
-CLAUDE_CODE_SESSION_ROUTER_BINDING_TARGET: Final = "claude_code_session_router_binding"
-
-_PRE_CALL_CHECK_FILTERED_PARAMS: Final = frozenset({"response_format"})
-_STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS: Final = frozenset({"anthropic_messages", "aresponses", "responses"})
-_video_call_deployment_id: Final[ContextVar[str | None]] = ContextVar("video_call_deployment_id", default=None)
-_routed_call_type: Final[ContextVar[str | None]] = ContextVar("routed_call_type", default=None)
-_DECISIONS_MEDIA_CALL_TYPES: Final = frozenset({"adecisions", "decisions", "asystemone", "systemone"})
-_ROUTE_CAPABILITY_RESOLVERS: Final[Mapping[str, Callable[[str, LlmProviders], object | None]]] = MappingProxyType(
- {
- "adecisions": ProviderConfigManager.get_provider_decisions_config,
- "decisions": ProviderConfigManager.get_provider_decisions_config,
- }
-)
-_RUNTIME_TOGGLEABLE_PRE_CALL_CHECKS: Final[Mapping[str, type[CustomLogger]]] = MappingProxyType(
- {
- "prompt_caching": PromptCachingDeploymentCheck,
- "enforce_model_rate_limits": ModelRateLimitingCheck,
- "encrypted_content_affinity": EncryptedContentAffinityCheck,
- }
-)
-
-
-def _stream_chunks_have_generated_content(chunks: Sequence[ModelResponseStream]) -> bool:
- for chunk in chunks:
- if not chunk.choices:
- continue
- delta = chunk.choices[0].delta
- if (
- delta.get("content")
- or delta.get("tool_calls")
- or delta.get("function_call")
- or delta.get("reasoning_content")
- or delta.get("thinking_blocks")
- or delta.get("reasoning_items")
- or delta.get("audio")
- or delta.get("images")
- or delta.get("annotations")
- ):
- return True
- return False
-
-
-_NO_SESSION_KWARGS: Final[Mapping[str, Mapping[str, object]]] = MappingProxyType({})
-_NO_DEFAULT_PARAMS: Final[Mapping[str, object]] = MappingProxyType({})
-_SESSION_ADAPTER: Final = TypeAdapter(Mapping[str, object])
-_MODEL_INFO_ADAPTER: Final = TypeAdapter(Mapping[str, object])
-_LITELLM_PARAMS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
-_SILENT_MODEL_ADAPTER: Final = TypeAdapter(str | list[str])
-_RESOLVED_RETRY_POLICY_ADAPTER: Final = TypeAdapter(RetryPolicy | None)
-_ROUTING_KWARGS_ADAPTER: Final[TypeAdapter[Mapping[str, object] | None]] = TypeAdapter(Mapping[str, object] | None)
-_FALLBACK_HOP_ADAPTER: Final = TypeAdapter(Mapping[str, object])
-_REQUEST_KWARGS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
-_DEPLOYMENT_PARAMS_ADAPTER: Final[TypeAdapter[Mapping[str, object] | GenericLiteLLMParams]] = TypeAdapter(
- Mapping[str, object] | GenericLiteLLMParams
-)
-_OPAQUE_VALUE_ADAPTER: Final = TypeAdapter(object)
-_DEPLOYMENT_SELECTED_EVENT: Final = "litellm.request.deployment_selected"
-_CONTENT_BLOCKS_ADAPTER: Final = TypeAdapter(list[dict[str, object]])
-_DEPLOYMENTS_ADAPTER: Final = TypeAdapter(Sequence[Mapping[str, object]])
-
-
-def _extend_trailing_text_block(blocks: Sequence[Mapping[str, object]], text: str) -> Sequence[Mapping[str, object]]:
- """Continue the last text block in place so the prefill reads as the text the caller already received."""
- last: Final = blocks[-1] if blocks else None
- if last is not None and last.get("type") == "text" and isinstance(last_text := last.get("text"), str):
- return [*blocks[:-1], {**last, "text": f"{last_text}{text}"}]
- return [*blocks, {"type": "text", "text": text}]
-
-
-def _is_fallback_hop(request_kwargs: Mapping[str, object]) -> bool:
- fallback_depth: Final = request_kwargs.get("fallback_depth")
- return isinstance(fallback_depth, int) and fallback_depth > 0
-
-
-def _deployment_that_just_failed(request_metadata: object) -> str | None:
- try:
- model_info: Final = _FALLBACK_HOP_ADAPTER.validate_python(
- _FALLBACK_HOP_ADAPTER.validate_python(request_metadata).get("model_info")
- )
- except ValidationError:
- return None
- model_id: Final = model_info.get("id")
- return model_id if isinstance(model_id, str) else None
-
-
-def _drops_malformed_request_tools(
- deployment_params: Mapping[str, object], defaults: Mapping[str, object], request: Mapping[str, object]
-) -> bool:
- if isinstance(request.get("tools") or [], (list, tuple)) or not deployment_params.get("tools"):
- return False
- set_defaults: Final = {name: value for name, value in defaults.items() if value is not None}
- return drop_policy({**deployment_params, **set_defaults, **request}).drops("tools")
-
-
-def _malformed_request_params_the_deployment_sets(
- deployment_params: Mapping[str, object], defaults: Mapping[str, object], request: Mapping[str, object]
-) -> frozenset[str]:
- overrides: Final = {
- key: value
- for key, value in request.items()
- if key in SUPPLEMENTAL_KWARGS_KEYS and deployment_params.get(key) is not None
- }
- if not overrides:
- return frozenset()
- try:
- validate_litellm_params_strictly(GenericLiteLLMParams, overrides)
- except ValidationError as error:
- set_defaults: Final = {name: value for name, value in defaults.items() if value is not None}
- policy: Final = drop_policy({**deployment_params, **set_defaults, **request})
- malformed: Final = (str(detail["loc"][0]) for detail in error.errors() if detail["loc"])
- return frozenset(key for key in malformed if policy.drops(key))
- return frozenset()
-
-
-def _drop_malformed_request_params_the_deployment_sets(
- kwargs: dict[str, object], # mutable-ok: drops the keys from the router's request kwargs in place
- deployment_params: Mapping[str, object],
- defaults: Mapping[str, object],
-) -> None:
- for key in _malformed_request_params_the_deployment_sets(deployment_params, defaults, kwargs):
- kwargs.pop(key)
-
-
-def _merged_tools(deployment_tools: Sequence[object], request_tools: object) -> list[object] | None:
- if not isinstance(request_tools, (list, tuple)):
- return None
- return [*deployment_tools, *request_tools] if deployment_tools or request_tools else None
-
-
-def _deployment_pick_attributes(model: str, request_kwargs: Mapping[str, object] | None) -> Mapping[str, str | int]:
- """Bounded attributes for one deployment pick; attempt is 1-based within the current model group."""
- kwargs: Final = request_kwargs or {}
- metadata: Final = kwargs.get("litellm_metadata", kwargs.get("metadata"))
- attempted_retries: Final = metadata.get("attempted_retries") if isinstance(metadata, Mapping) else None
- retries: Final = attempted_retries if isinstance(attempted_retries, int) else 0
- reason: Final = "retry" if retries > 0 else "fallback" if _is_fallback_hop(kwargs) else "initial"
- return MappingProxyType(
+ _MISSING: Final = object()
+ _replaced_core_members: Final[ # mutable-ok: originals of patched core members, restored on delete
+ dict[tuple[type, str], list[object]]
+ ] = {}
+ _FACADE_ONLY: Final = frozenset(
{
- "litellm.deployment.attempt": retries + 1,
- "litellm.deployment.reason": reason,
- "litellm.deployment.model_group": model,
+ "__module__",
+ "__qualname__",
+ "__doc__",
+ "__dict__",
+ "__weakref__",
+ "__init__",
+ "__init_subclass__",
+ "__annotations__",
+ "__firstlineno__",
+ "__static_attributes__",
+ "__classcell__",
+ "_core_class",
}
)
-
-def _configured_model_info(deployment: Mapping[str, object]) -> Mapping[str, object]:
- return _MODEL_INFO_ADAPTER.validate_python(deployment.get("model_info") or {})
-
-
-def _search_tool_support(deployment: DeploymentTypedDict) -> bool | None:
- from litellm.responses.litellm_completion_transformation.tool_search import chat_config_builds_anthropic_request
-
- configured: Final = _configured_model_info(deployment).get("supports_search_tool")
- if isinstance(configured, bool):
- return configured
- params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(deployment["litellm_params"])
- model: Final = params.get("model")
- provider: Final = params.get("custom_llm_provider")
- if not isinstance(model, str):
- return None
- try:
- resolved_model, resolved_provider = resolve_model_and_provider_for_metadata(
- model=model, custom_llm_provider=provider if isinstance(provider, str) else None
- )
- chat_config: Final = ProviderConfigManager.get_provider_chat_config(
- model=resolved_model, provider=LlmProviders(resolved_provider)
- )
- except (litellm.BadRequestError, ValueError):
- return None
- return True if chat_config_builds_anthropic_request(chat_config) else None
-
-
-def _every_deployment_refused(
- deployments: Sequence[Mapping[str, object]], refused_deployment_ids: tuple[str, ...]
-) -> bool:
- return all(_configured_model_info(deployment).get("id") in refused_deployment_ids for deployment in deployments)
-
-
-def _silent_experiment_targets(silent_model: object) -> tuple[str, ...]:
- if silent_model is None or in_mirrored_call():
- return ()
- try:
- targets: Final = _SILENT_MODEL_ADAPTER.validate_python(silent_model)
- except ValidationError:
- verbose_router_logger.warning(
- "silent_model must be a model name or a list of model names, got %r; skipping shadow traffic",
- silent_model,
- )
- return ()
- return (targets,) if isinstance(targets, str) else tuple(targets)
-
-
-def _silent_experiment_kwargs_snapshot(kwargs: Mapping[str, object]) -> Mapping[str, object]:
- metadata: Final = kwargs.get("metadata")
- if not isinstance(metadata, Mapping):
- return MappingProxyType({**kwargs})
- return MappingProxyType({**kwargs, "metadata": dict(metadata)})
-
-
-def _with_router_resolved_session_model(session: object, model_name: str) -> Mapping[str, Mapping[str, object]]:
- """
- Realtime client-secret requests carry the model inside ``session`` as well, and the caller's copy of it still
- holds the pre-routing model group name, so it has to follow the deployment the router just picked.
-
- Returns kwargs to merge into the downstream call, empty when there is no session model to resolve.
- """
- try:
- typed_session: Final = _SESSION_ADAPTER.validate_python(session)
- except ValidationError:
- return _NO_SESSION_KWARGS
- if "model" not in typed_session:
- return _NO_SESSION_KWARGS
- return MappingProxyType({"session": {**typed_session, "model": model_name}})
-
-
-# Router._aanthropic_messages_streaming_iterator buffers lifecycle chunks
-# until real content commits the primary stream, and only while a fallback
-# can still take over; a hostile or slow-starting upstream that never emits
-# content or an error could otherwise grow that buffer without bound, so
-# hitting this cap forces an early commit instead.
-MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS: Final = 200
-
-
-def _anthropic_stream_forwards_ping_live(chunk: object, has_generated_content: bool) -> bool:
- """A `ping` keepalive reaches the client live whenever the stream has not committed: it carries no
- lifecycle, so it cannot create overlapping lifecycles on the wire, and it keeps the connection alive
- while lifecycle frames sit buffered for a possible fallback during a long thinking pass."""
- from litellm.llms.anthropic.messages.native.streaming_iterator import is_anthropic_ping_chunk
-
- return not has_generated_content and is_anthropic_ping_chunk(chunk)
-
-
-def _is_retriable_anthropic_status(status_code: int) -> bool:
- return status_code == 429 or status_code >= 500
-
-
-def _without_line_breaks(value: object) -> str:
- return str(value).replace("\r", "").replace("\n", "")
-
-
-def _anthropic_stream_error_is_gateway_verdict(chunk: object) -> bool:
- """AgenticAnthropicStreamingIterator's own retrieval-failure frame is the gateway's verdict, not a provider
- failure: another deployment would rerun the same failed hook, so it reaches the client instead of falling back."""
- from litellm.llms.anthropic.messages.native.agentic_streaming_iterator import (
- is_server_fulfilled_tool_leak_error,
- )
-
- return is_server_fulfilled_tool_leak_error(chunk)
-
-
-def _anthropic_stream_should_decline_fallback(has_generated_content: bool, error: "MidStreamFallbackError") -> bool:
- """
- A MidStreamFallbackError raised directly by the source iterator (the
- completion-bridge path's CustomStreamWrapper, e.g. on a transport drop)
- carries its own pre_first_chunk bookkeeping - gated the same way a
- detected SSE error event is, so a fallback is never appended after real
- content already reached the client on either path.
- """
- return has_generated_content or not error.is_pre_first_chunk
-
-
-def _stream_raised_error_status(error: Exception) -> int | None:
- raw_status: Final = getattr(error, "status_code", None)
- if isinstance(raw_status, int):
- return raw_status
- if isinstance(raw_status, str) and raw_status.isdigit():
- return int(raw_status)
- response_status: Final = getattr(getattr(error, "response", None), "status_code", None)
- return response_status if isinstance(response_status, int) else None
-
-
-def _stream_fallback_error_for_raised(
- error: Exception, model: str, has_generated_content: bool, llm_provider: str = "anthropic"
-) -> "MidStreamFallbackError | None":
- """The pre-stream retry rule (408, 409, 429, 5xx); None means the raise propagates unchanged."""
- if has_generated_content:
- return None
- status_code: Final = _stream_raised_error_status(error)
- if status_code is None:
- return _stream_pre_content_error(error, model, llm_provider)
- retriable: Final = litellm.should_retry(status_code)
- return _stream_pre_content_error(error, model, llm_provider) if retriable else None
-
-
-def _stream_pre_content_error(
- error: Exception, model: str, llm_provider: str = "anthropic"
-) -> "MidStreamFallbackError":
- """The envelope the fallback chain judges a failure by when the client has received no content yet."""
- from litellm.exceptions import MidStreamFallbackError
-
- return MidStreamFallbackError(
- message=str(error),
- model=model,
- llm_provider=llm_provider,
- original_exception=error,
- is_pre_first_chunk=True,
- )
-
-
-def _context_window_stream_fallback_error(
- error: "MidStreamFallbackError | litellm.ContextWindowExceededError",
- model: object,
- llm_provider: str,
- has_output: bool,
-) -> "MidStreamFallbackError":
- if isinstance(error, litellm.ContextWindowExceededError):
- if has_output:
- raise error
- return _stream_pre_content_error(error, model if isinstance(model, str) else "", llm_provider)
- if isinstance(error.original_exception, litellm.ContextWindowExceededError) and (
- has_output or error.generated_content or error.emitted_disqualifying_content
- ):
- raise error.original_exception
- return error
-
-
-def _deployment_num_retries(deployment: "Deployment | None") -> int | None:
- """The deployment's own num_retries litellm_param, an int or a digit string the way the config loader leaves it."""
- configured: Final = getattr(deployment.litellm_params, "num_retries", None) if deployment is not None else None
- if isinstance(configured, bool) or not isinstance(configured, (int, str)):
- return None
- return int(configured) if str(configured).isdigit() else None
-
-
-def _request_fallback_list(
- kwargs: Mapping[str, object], key: str, router_default: "list[object] | None"
-) -> "list[object] | None": # mutable-ok: should_retry_this_error's own parameter type
- return cast("list[object] | None", kwargs.get(key, router_default)) # cast-ok: same type as the router attribute
-
-
-def _request_model_group(kwargs: Mapping[str, object]) -> str | None:
- model_group: Final = kwargs.get("model")
- return model_group if isinstance(model_group, str) else None
-
-
-def _mid_stream_error_before_output(error: "MidStreamFallbackError") -> bool:
- return not error.generated_content or error.is_pre_first_chunk
-
-
-def _mid_stream_retry_trigger(error: "MidStreamFallbackError") -> Exception:
- """The provider's own error, which is what the retry policy and should_retry_this_error classify."""
- return error.original_exception if error.original_exception is not None else error
-
-
-_RestT: Final = TypeVar("_RestT", Iterator[object], AsyncIterator[object])
-_RouteDeploymentsT: Final = TypeVar("_RouteDeploymentsT", bound=Sequence[Mapping[str, object]] | Mapping[str, object])
-
-
-@dataclass(frozen=True, slots=True)
-class _FirstChunk(Generic[_RestT]):
- """
- The chunk Router._acompletion / _completion already read off a stream (None: the
- provider closed it empty) and the iterator it was read from, which carries on after it.
- """
-
- chunk: ModelResponseStream | None
- rest: _RestT
-
-
-async def _aread_first_chunk(stream: CustomStreamWrapper) -> _FirstChunk[AsyncIterator[object]]:
- """
- Read the first chunk while Router._acompletion still holds the request, so a
- stream that fails before any token raises there, inside async_function_with_retries
- and the fallback chain, instead of surfacing to the caller mid-iteration.
-
- Iterates the way `async for` does, one __aiter__ call and then __anext__ on what it
- returned: a CustomStreamWrapper subclass may hand iteration to another object and
- leave completion_stream None (the MCP gateway's MCPStreamWrapper).
- """
- from litellm.exceptions import MidStreamFallbackError
-
- rest: Final = stream.__aiter__()
- try:
- return _FirstChunk(chunk=await rest.__anext__(), rest=rest)
- except StopAsyncIteration:
- return _FirstChunk(chunk=None, rest=rest)
- except MidStreamFallbackError as e:
- raise _mid_stream_retry_trigger(e) from e
-
-
-def _read_first_chunk(stream: CustomStreamWrapper) -> _FirstChunk[Iterator[object]]:
- """Sync twin of _aread_first_chunk for Router._completion."""
- from litellm.exceptions import MidStreamFallbackError
-
- rest: Final = iter(stream)
- try:
- return _FirstChunk(chunk=next(rest), rest=rest)
- except StopIteration:
- return _FirstChunk(chunk=None, rest=rest)
- except MidStreamFallbackError as e:
- raise _mid_stream_retry_trigger(e) from e
-
-
-def _chunks_with_first(
- model_response: Iterable[object], taken: _FirstChunk[Iterator[object]] | None
-) -> Iterator[object]:
- """The stream with the chunk _read_first_chunk already read put back in front (None: the stream as is)."""
- if taken is None:
- yield from model_response
- return
- if taken.chunk is None:
- return
- yield taken.chunk
- yield from taken.rest
-
-
-async def _achunks_with_first(
- model_response: AsyncIterable[object], taken: _FirstChunk[AsyncIterator[object]] | None
-) -> AsyncIterator[object]:
- """Async twin of _chunks_with_first."""
- if taken is None:
- async for item in model_response:
- yield item
- return
- if taken.chunk is None:
- return
- yield taken.chunk
- async for item in taken.rest:
- yield item
-
-
-def _stream_commits_now(chunk: object, has_generated_content: bool, buffered_chunk_count: int) -> bool:
- """
- Whether `chunk` should make Router._aanthropic_messages_streaming_iterator
- commit to the primary Anthropic stream (real content arrived, or the
- pre-content buffer cap was hit) rather than keep buffering lifecycle
- frames toward a possible fallback.
- """
- from litellm.llms.anthropic.messages.native.streaming_iterator import (
- is_anthropic_content_delta_chunk,
- )
-
- if has_generated_content:
- return False
- return is_anthropic_content_delta_chunk(chunk) or buffered_chunk_count >= MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS
-
-
-@dataclass(frozen=True, slots=True)
-class _StreamRetryOpened:
- response: object
- attempted_retries: int
- max_retries: int
-
-
-@dataclass(frozen=True, slots=True)
-class _StreamRetriesExhausted:
- error: "MidStreamFallbackError"
-
-
-_StreamRetryOutcome: TypeAlias = _StreamRetryOpened | _StreamRetriesExhausted
-
-
-MAX_HELD_PRE_OUTPUT_RESPONSES_EVENTS: Final = 200
-
-
-def _retry_policy_ceiling(policy: RetryPolicy) -> int:
- """The most retries any error class under this policy can be granted."""
- retry_counts: Final[tuple[object, ...]] = tuple(policy.model_dump().values())
- return max((retries for retries in retry_counts if isinstance(retries, int)), default=0)
-
-
-def _responses_stream_holds_event(item: object, held_event_count: int) -> bool:
- from litellm.responses.streaming_iterator import PRE_OUTPUT_LIFECYCLE_EVENT_TYPES
-
- if held_event_count >= MAX_HELD_PRE_OUTPUT_RESPONSES_EVENTS:
- return False
- return getattr(item, "type", None) in PRE_OUTPUT_LIFECYCLE_EVENT_TYPES
-
-
-def _responses_event_announces_background_run(item: object) -> bool:
- return getattr(getattr(item, "response", None), "background", None) is True
-
-
-class FallbackAwareAnthropicMessagesStream:
- """
- Bare async generators can't carry the `_hidden_params` attribute the
- proxy reads response headers off of (see
- router_utils.add_retry_fallback_headers.get_hidden_params_dict), so this
- thin wrapper carries it through from the source iterator - mirrors
- AnthropicMessagesStreamingResponse. Used by
- Router._aanthropic_messages_streaming_iterator.
- """
-
- def __init__(self, async_generator: AsyncGenerator[bytes, None], source_iterator: object) -> None:
- self._async_generator = async_generator
- self._source_iterator = source_iterator
- self.fallback_headers_adopted = False
- self._hidden_params = dict(getattr(source_iterator, "_hidden_params", None) or {})
- self._followed_source_params: object = None
-
- @property
- def has_buffered_provider_output(self) -> bool:
- return getattr(self._source_iterator, "has_buffered_provider_output", False) is True
-
- @property
- def chunks(self) -> list[ModelResponseStream] | None:
- return cast( # cast-ok: chunks is a list of ModelResponseStream on the inner stream
- "list[ModelResponseStream] | None", getattr(self._source_iterator, "chunks", None)
- )
-
- @property
- def messages(self) -> list[AllMessageValues] | None:
- return cast( # cast-ok: messages is a list of AllMessageValues on the inner stream
- "list[AllMessageValues] | None", getattr(self._source_iterator, "messages", None)
- )
-
- @property
- def model(self) -> str | None:
- return cast( # cast-ok: model is a str on the inner stream
- "str | None", getattr(self._source_iterator, "model", None)
- )
-
- def adopt_fallback_source(self, fallback_response: object) -> None:
- self._source_iterator = fallback_response
- self.fallback_headers_adopted = True
-
- def partial_response_on_disconnect(self) -> "ResponseIncompleteEvent | None":
- from litellm.responses.streaming_iterator import partial_responses_incomplete_event_on_disconnect
-
- return partial_responses_incomplete_event_on_disconnect(self._source_iterator)
-
- def follow_source_attribution(self) -> None:
- """
- A retry's or a fallback's stream carries a wrapper of its own, so a hop it makes before its
- first byte lands on that wrapper while the proxy reads the headers off this one. Mirrors the
- source's current attribution onto this wrapper whenever the source has adopted a new one.
- """
- source: Final = self._source_iterator
- if not getattr(source, "fallback_headers_adopted", False):
- return
- source_params: Final = getattr(source, "_hidden_params", None)
- if source_params is None or source_params is self._followed_source_params:
- return
- self._followed_source_params = source_params
- hidden_params, headers = Router._prepare_fallback_hidden_params(source) # pyright: ignore[reportPrivateUsage] # this wrapper is the Router's own stream type
- self.merge_fallback_hidden_params(hidden_params, headers)
-
- def __aiter__(self) -> "FallbackAwareAnthropicMessagesStream":
- return self
-
- async def __anext__(self) -> bytes:
- return await self._async_generator.__anext__()
-
- async def aclose(self) -> None:
- await self._async_generator.aclose()
-
- def merge_fallback_hidden_params(
- self,
- fallback_hidden_params: Mapping[str, object],
- fallback_headers: Mapping[str, object],
- ) -> None:
- """
- Raw bytes can't carry their own _hidden_params the way a
- ModelResponseStream/ResponsesAPI event can, so a mid-stream
- fallback's provider headers (e.g. Bedrock's x-amzn-requestid) are
- merged onto the wrapper itself instead - mirrors
- Router._apply_fallback_hidden_params_to_item's merge shape.
- """
- existing_headers: Final = cast( # cast-ok: additional_headers is always a dict[str, object] when present
- "dict[str, object]", self._hidden_params.get("additional_headers") or {}
- )
- self._hidden_params = {
- **self._hidden_params,
- **fallback_hidden_params,
- "additional_headers": dict(replace_complexity_router_headers(existing_headers, fallback_headers)),
- }
-
-
-class RoutingArgs(enum.Enum):
- ttl = 60 # 1min (RPM/TPM expire key)
-
-
-# Routers that are still in use, so a price data reload can rebuild the cost-map
-# entries their deployments own. Weak so a router nothing references any more, such
-# as the per-request one built from a caller-supplied user_config, drops out on its
-# own rather than leaving entries behind that nothing can withdraw.
-_live_routers: Final["weakref.WeakSet[Router]"] = weakref.WeakSet()
-_routers_holding_global_callbacks: Final["weakref.WeakSet[Router]"] = weakref.WeakSet()
-_routers_holding_global_callbacks_lock: Final = threading.Lock()
-
-
-def _replay_live_router_model_cost() -> None:
- """Re-assert every live router's deployments after the cost map is refreshed."""
- for router in tuple(_live_routers):
- router.replay_model_cost_registrations()
-
-
-set_live_deployment_replay(_replay_live_router_model_cost)
-
-
-RETRY_BREADCRUMB_LIMIT: Final = 4
-
-
-class FallbackAwareStreamWrapper(CustomStreamWrapper):
- """Base for the Router's chat-completion stream wrappers, which are built around the
- attempt the Router picked first and have to repoint themselves when a fallback takes over."""
-
- fallback_headers_adopted: bool = False
-
- def adopt_fallback_response_headers(
- self,
- fallback_response: object,
- prepared_fallback_hidden_params: tuple[dict[str, object], dict[str, object]],
- ) -> None:
- """Repoint this wrapper at the deployment that served the stream.
-
- Replaces rather than merges, so the failed attempt's `x-request-id`, rate limit
- counters, `model_id` and `api_base` cannot reach the proxy's response headers or
- its callbacks.
- """
- self._response_headers = getattr(fallback_response, "_response_headers", None)
- fallback_hidden_params, fallback_headers = prepared_fallback_hidden_params
- if fallback_hidden_params:
- self._hidden_params = {
- **fallback_hidden_params,
- # dict() because add_retry_fallback_headers mutates additional_headers in place
- "additional_headers": dict(fallback_headers),
- }
- self._base_hidden_params = {
- **self._hidden_params,
- "response_cost": None,
- }
- self.fallback_headers_adopted = True
-
-
-def as_output_cap(value: object) -> int | None:
- """A client-sent output cap coerced to an int: ints, floats and numeric strings, never bools
- or negatives."""
- if isinstance(value, bool) or not isinstance(value, (int, float, str)):
- return None
- try:
- cap: Final = int(float(value))
- except (ValueError, OverflowError):
- return None
- return cap if cap >= 0 else None
-
-
-def stamped_client_ceiling(request_kwargs: Mapping[str, object]) -> Mapping[str, int] | None:
- """None means unstamped; an empty mapping means the original caller supplied no limit."""
- stamped: Final = next(
- (
- bucket.get(CLIENT_OUTPUT_CEILING_METADATA_KEY)
- for bucket in (request_kwargs.get("metadata"), request_kwargs.get("litellm_metadata"))
- if isinstance(bucket, dict) and CLIENT_OUTPUT_CEILING_METADATA_KEY in bucket
- ),
- None,
- )
- if not isinstance(stamped, dict):
- return None
- return MappingProxyType(
- {
- carrier: cap
- for carrier, value in stamped.items()
- if carrier in OUTPUT_TOKEN_CEILING_PARAMS and (cap := as_output_cap(value)) is not None
- }
- )
-
-
-def _exact_base_model_max_input_tokens(base_model: str) -> int | None:
- try:
- model_info: Final = litellm.get_model_info(model=base_model)
- except Exception: # noqa: BLE001 # get_model_info raises ModelNotMappedError or a bare Exception for an unmapped model
- return None
- if is_generalized_model_info(model_info):
- return None
- return model_info.get("max_input_tokens")
-
-
-def _provider_model_id(model_name: str, custom_llm_provider: str) -> str:
- model_id: Final = model_name.rsplit("/", 1)[-1]
- return get_bedrock_base_model(model_id) if custom_llm_provider.startswith("bedrock") else model_id
-
-
-def _names_route_model(base_model: str, route_key: str, custom_llm_provider: str) -> bool:
- route_model_id: Final = _provider_model_id(route_key, custom_llm_provider)
- base_model_id: Final = _provider_model_id(base_model, custom_llm_provider)
- return route_model_id in (base_model_id, base_model_id.split("@", 1)[0])
-
-
-def _declared_base_model(user_model_info: Mapping[str, object]) -> str | None:
- match user_model_info:
- case {"base_model": str() as base_model}:
- return base_model
- case _:
- return None
-
-
-def _base_model_widened_window(
- route_model_info: ModelMapInfo, base_model: str | None, custom_llm_provider: str
-) -> Mapping[str, int]:
- route_max_input_tokens: Final = route_model_info.get("max_input_tokens")
- if base_model is None or route_max_input_tokens is None:
- return MappingProxyType({})
- if not _names_route_model(base_model, route_model_info["key"], custom_llm_provider):
- return MappingProxyType({})
- base_max_input_tokens: Final = _exact_base_model_max_input_tokens(base_model)
- if base_max_input_tokens is None or base_max_input_tokens <= route_max_input_tokens:
- return MappingProxyType({})
- return MappingProxyType({"max_input_tokens": base_max_input_tokens})
-
-
-_SETTING_ATTRIBUTES: Final = frozenset("_core_" + name for name in SETTING_FIELDS)
-
-
-class Router(DeprecatedRouterMembers):
- _core_model_names: set[str] = set()
- _core_cache_responses: bool | None = False
- _registry_version: int = 0
-
- # -- entry points: everything outside code needs besides the endpoint methods ---------------
- # See litellm/router_api.py. The members outside code used before these live in
- # DeprecatedRouterMembers (litellm/router_deprecated.py).
-
- def registry(self) -> RouterRegistry:
- """Deployment lookups: by id, group, alias, wildcard and team, plus configured plugins."""
- cached: Final = self.__dict__.get("_registry_view")
- if isinstance(cached, RouterRegistry):
- return cached
- view: Final = RouterRegistry(self)
- self.__dict__["_registry_view"] = view
- return view
-
- def settings(self) -> RouterSettings:
- """
- A frozen snapshot of the router's configuration. It is built once and reused until a
- setting attribute is rebound or deleted (``__setattr__``/``__delattr__`` drop it), so the
- request path can read it cheaply.
- """
- cached: Final = self.__dict__.get("_settings_snapshot")
- if isinstance(cached, RouterSettings):
- return cached
- snapshot: Final = RouterSettings.of(self)
- self.__dict__["_settings_snapshot"] = snapshot
- return snapshot
-
- def __setattr__(self, name: str, value: object) -> None:
- if name in _SETTING_ATTRIBUTES:
- self.__dict__.pop("_settings_snapshot", None)
- elif name == "_core_model_list" and "_core_model_list" in self.__dict__:
- # A whole new deployment list (``router.model_list = ...``) is a deployment change too.
- self.__dict__["_registry_version"] = self._registry_version + 1
- super().__setattr__(name, value)
-
- def __delattr__(self, name: str) -> None:
- if name in _SETTING_ATTRIBUTES:
- self.__dict__.pop("_settings_snapshot", None)
- super().__delattr__(name)
-
- def health(self) -> RouterHealth:
- """Cooldowns, blocked deployments, health checks and usage."""
- cached: Final = self.__dict__.get("_health_view")
- if isinstance(cached, RouterHealth):
- return cached
- view: Final = RouterHealth(self)
- self.__dict__["_health_view"] = view
- return view
-
- @overload
- async def select(
- self,
- model: str,
- request_kwargs: dict[str, object] | None = None, # mutable-ok: the selection cores take a dict
- *,
- kind: Literal["pick", "pass_through"] = "pick",
- messages: list[dict[str, str]] | None = None, # mutable-ok: the selection cores take a list
- input: str | list[object] | None = None, # mutable-ok: the selection cores take a list
- specific_deployment: bool | None = False,
- parent_otel_span: Span | None = None,
- health_check_probe: bool = False,
- ) -> "UntypedDict": ... # a mutable dict, as before: the cores' untyped deployment dict, unchanged
-
- @overload
- async def select(
- self,
- model: str,
- request_kwargs: dict[str, object] | None = None, # mutable-ok: the selection cores take a dict
- *,
- kind: Literal["healthy"],
- messages: list[dict[str, str]] | None = None, # mutable-ok: the selection cores take a list
- input: str | list[object] | None = None, # mutable-ok: the selection cores take a list
- specific_deployment: bool | None = False,
- parent_otel_span: Span | None = None,
- health_check_probe: bool = False,
- ) -> "list[UntypedDict] | UntypedDict": ... # mutable-ok: the core's type (list[dict] | dict), unchanged
-
- async def select( # pyright: ignore[reportUnknownParameterType] # the selection cores return untyped deployment dicts
- self,
- model: str,
- request_kwargs: dict[str, object] | None = None, # mutable-ok: the selection cores take a dict
- *,
- kind: SelectKind = "pick",
- messages: list[dict[str, str]] | None = None, # mutable-ok: the selection cores take a list
- input: str | list[object] | None = None, # mutable-ok: the selection cores take a list
- specific_deployment: bool | None = False,
- parent_otel_span: Span | None = None,
- health_check_probe: bool = False,
- ):
- """
- Pick a deployment for ``model`` without calling it (``pick``), list the deployments that
- are healthy for the request (``healthy``), or pick one for a pass-through route
- (``pass_through``).
- """
- if kind == "pick":
- return await self._core_async_get_available_deployment( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the selection cores return untyped deployment dicts
- model=model,
- request_kwargs=request_kwargs if request_kwargs is not None else {},
- messages=messages,
- input=input,
- specific_deployment=specific_deployment,
- )
- if kind == "healthy":
- return await self._core_async_get_healthy_deployments( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the selection cores return untyped deployment dicts
- model=model,
- request_kwargs=request_kwargs if request_kwargs is not None else {},
- messages=messages,
- input=input,
- specific_deployment=specific_deployment,
- parent_otel_span=parent_otel_span,
- health_check_probe=health_check_probe,
- )
- if kind == "pass_through":
- return self._core_get_available_deployment_for_pass_through( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the selection cores return untyped deployment dicts
- model=model,
- messages=messages,
- input=input,
- specific_deployment=specific_deployment,
- request_kwargs=request_kwargs,
- )
- raise ValueError(f"unknown select kind {kind!r}")
-
- def apply(self, changes: ChangeSet) -> ApplyResult:
- """Apply runtime changes: settings, deployments, the routing state's Redis cache."""
- return apply_changes(self, changes)
-
- async def aapply(self, changes: ChangeSet) -> ApplyResult:
- """``apply``, plus the changes that need I/O (``refresh_model_info``)."""
- return await aapply_changes(self, changes)
-
- def observe(self, logging_obj: "LiteLLMLogging | None" = None, *, request_scoped: bool = False) -> RoutingObservers:
- """
- The router's routing observers. ``request_scoped=True`` stops registering them globally,
- so each request binds them instead; passing a request's ``logging_obj`` binds them to it.
- """
- if request_scoped:
- self._core_serve_through_request_logging()
- if logging_obj is not None:
- self._core_bind_to_request_logging(logging_obj)
- return RoutingObservers(slack_alerting_logger=getattr(self, "_core_slack_alerting_logger", None))
-
- def register_plugin(self, plugin: CustomRoutingStrategyBase) -> None:
- """Route with a custom strategy instead of the configured one."""
- self._core_set_custom_routing_strategy(plugin)
-
- def retire(self) -> None:
- """Release the router's global callback registrations and routing state."""
- self._core_discard()
-
- default_cache_time_seconds: int = 1 * 60 * 60 # 1 hour
- tenacity = None
- leastbusy_logger: LeastBusyLoggingHandler | None = None
- lowesttpm_logger: LowestTPMLoggingHandler | None = None
- optional_callbacks: list[CustomLogger | Callable | str] | None = None
-
- def __init__(
- self,
- model_list: list[DeploymentTypedDict] | list[dict[str, Any]] | None = None,
- ## ASSISTANTS API ##
- assistants_config: AssistantsTypedDict | None = None,
- ## SEARCH API ##
- search_tools: list[SearchToolTypedDict] | None = None,
- ## GUARDRAIL API ##
- guardrail_list: list[GuardrailTypedDict] | None = None,
- ## CACHING ##
- redis_url: str | None = None,
- redis_host: str | None = None,
- redis_port: int | None = None,
- redis_password: str | None = None,
- redis_db: int | None = None,
- cache_responses: bool | None = False,
- cache_kwargs: dict = {}, # additional kwargs to pass to RedisCache (see caching.py)
- caching_groups: list[tuple] | None = None, # if you want to cache across model groups
- client_ttl: int = 3600, # ttl for cached clients - will re-initialize after this time in seconds
- ## SCHEDULER ##
- polling_interval: float | None = None,
- default_priority: int | None = None,
- ## RELIABILITY ##
- num_retries: int | None = None,
- max_fallbacks: int | None = None, # max fallbacks to try before exiting the call. Defaults to 5.
- timeout: float | None = None,
- stream_timeout: float | None = None,
- default_litellm_params: dict | None = None, # default params for Router.chat.completion.create
- default_max_parallel_requests: int | None = None,
- set_verbose: bool = False,
- debug_level: Literal["DEBUG", "INFO"] = "INFO",
- default_fallbacks: list[str] | None = None, # generic fallbacks, works across all deployments
- fallbacks: list = [],
- context_window_fallbacks: list = [],
- content_policy_fallbacks: list = [],
- model_group_alias: dict[str, str | RouterModelGroupAliasItem] | None = {},
- enable_pre_call_checks: bool = False,
- enable_tag_filtering: bool = False,
- tag_filtering_match_any: bool = True,
- tag_routing_prefix: str = "",
- plugins: list[RoutingPlugin] | None = None,
- retry_after: int = 0, # min time to wait before retrying a failed request
- retry_policy: RetryPolicy | dict | None = None, # set custom retries for different exceptions
- model_group_retry_policy: dict[str, RetryPolicy] = {}, # set custom retry policies based on model group
- allowed_fails: int | None = None, # Number of times a deployment can failbefore being added to cooldown
- allowed_fails_policy: AllowedFailsPolicy | None = None, # set custom allowed fails policy
- cooldown_time: float | None = None, # (seconds) time to cooldown a deployment after failure
- disable_cooldowns: bool | None = None,
- fallback_on_unsupported_params: bool = True,
- routing_strategy: RoutingStrategyName = "simple-shuffle",
- optional_pre_call_checks: OptionalPreCallChecks | None = None,
- routing_strategy_args: dict = {}, # just for latency-based
- routing_groups: list[RoutingGroup | dict] | None = None,
- provider_budget_config: GenericBudgetConfigType | None = None,
- alerting_config: AlertingConfig | None = None,
- router_general_settings: RouterGeneralSettings | None = RouterGeneralSettings(),
- deployment_affinity_ttl_seconds: int = 3600,
- model_group_affinity_config: dict[str, list[str]] | None = None,
- ignore_invalid_deployments: bool = False,
- enable_health_check_routing: bool = False,
- health_check_staleness_threshold: int | None = None,
- health_check_ignore_transient_errors: bool = False,
- background_health_check_model_groups: Sequence[str] | None = None,
- enable_weighted_failover: bool = False,
- enable_mid_stream_fallback_continuation: bool = False,
- fallback_access_check: FallbackAccessCheck | None = None,
- fallback_budget_check: FallbackBudgetCheck | None = None,
- auto_router_capability_limit: AutoRouterCapabilityLimit | None = None,
- session_affinity_repin_on_failover: bool = False,
- ) -> None:
- """
- Initialize the Router class with the given parameters for caching, reliability, and routing strategy.
-
- Args:
- model_list (Optional[list]): List of models to be used. Defaults to None.
- redis_url (Optional[str]): URL of the Redis server. Defaults to None.
- redis_host (Optional[str]): Hostname of the Redis server. Defaults to None.
- redis_port (Optional[int]): Port of the Redis server. Defaults to None.
- redis_password (Optional[str]): Password of the Redis server. Defaults to None.
- cache_responses (Optional[bool]): Flag to enable caching of responses. Defaults to False.
- cache_kwargs (dict): Additional kwargs to pass to RedisCache. Defaults to {}.
- caching_groups (Optional[List[tuple]]): List of model groups for caching across model groups. Defaults to None.
- client_ttl (int): Time-to-live for cached clients in seconds. Defaults to 3600.
- polling_interval: (Optional[float]): frequency of polling queue. Only for '.scheduler_acompletion()'. Default is 3ms.
- default_priority: (Optional[int]): the priority given to every chat or text completion request that sets no 'priority' of its own; '.acompletion()' and '.atext_completion()' queue it through the scheduler. Default is None.
- num_retries (Optional[int]): Number of retries for failed requests. Defaults to 2.
- timeout (Optional[float]): Timeout for requests. Defaults to None.
- default_litellm_params (dict): Default parameters for Router.chat.completion.create. Defaults to {}.
- set_verbose (bool): Flag to set verbose mode. Defaults to False.
- debug_level (Literal["DEBUG", "INFO"]): Debug level for logging. Defaults to "INFO".
- fallbacks (List): List of fallback options. Defaults to [].
- context_window_fallbacks (List): List of context window fallback options. Defaults to [].
- enable_pre_call_checks (boolean): Filter out deployments which are outside context window limits for a given prompt
- model_group_alias (Optional[dict]): Alias for model groups. Defaults to {}.
- retry_after (int): Minimum time to wait before retrying a failed request. Defaults to 0.
- allowed_fails (Optional[int]): Number of allowed fails before adding to cooldown. Defaults to None.
- cooldown_time (float): Time to cooldown a deployment after failure in seconds. Defaults to 1.
- routing_strategy (Literal["simple-shuffle", "least-busy", "usage-based-routing", "latency-based-routing", "cost-based-routing"]): Routing strategy used for the implicit "default" group (any model not claimed by an entry in `routing_groups`). Defaults to "simple-shuffle".
- routing_strategy_args (dict): Additional args for the default group's routing strategy (e.g. latency window). Defaults to {}.
- routing_groups (Optional[List[RoutingGroup]]): Named subsets of `model_name`s with a group routing strategy.
- Priority groups apply only to group calls and may overlap. Other groups supply their members' default
- strategy, with at most one such group per model. Unclaimed models use the top-level strategy.
- alerting_config (AlertingConfig): Slack alerting configuration. Defaults to None.
- provider_budget_config (ProviderBudgetConfig): Provider budget configuration. Use this to set llm_provider budget limits. example $100/day to OpenAI, $100/day to Azure, etc. Defaults to None.
- deployment_affinity_ttl_seconds (int): TTL for user-key -> deployment affinity mapping. Defaults to 3600.
- session_affinity_repin_on_failover (bool): After a successful replacement response, move a session pin
- whose deployment is in cooldown or removed. Async routes only. Defaults to False. Applied at startup.
- ignore_invalid_deployments (bool): Ignores invalid deployments, and continues with other deployments. Default is to raise an error.
- enable_weighted_failover (bool): When True and the routing strategy is "simple-shuffle", a retryable failure on one deployment causes the request to re-pick (weighted) across the other deployments in the same model group before any cross-group fallback runs. Bounded by `max_fallbacks`. Async-only: currently honored by `router.acompletion()` and other async entrypoints. The sync `router.completion()` path falls back to the regular fallback flow. Defaults to False.
- enable_mid_stream_fallback_continuation (bool): When True, a chat-completions stream that breaks after plain assistant text has been delivered continues on a fallback deployment via assistant prefill instead of surfacing the error. Only deployments whose model supports assistant prefill are eligible, so the partial text is continued, not regenerated; if none is, the original error is surfaced. Streams that emitted tool calls, thinking blocks, audio/images, or constrained (JSON / forced tool_choice) output are never continued. Async-only. Defaults to False.
- fallback_access_check (Optional[FallbackAccessCheck]): Awaited before each cross-model-group fallback attempt on the async path; a fallback target it rejects is skipped. Defaults to None (every configured fallback is attempted).
- fallback_budget_check (Optional[FallbackBudgetCheck]): Awaited before each cross-model-group fallback attempt on the async path; a fallback target it rejects as over budget is skipped. Defaults to None (budget is not re-checked on fallback).
- Returns:
- Router: An instance of the litellm.Router class.
-
- Example Usage:
- ```python
- from litellm import Router
- model_list = [
- {
- "model_name": "azure-gpt-3.5-turbo", # model alias
- "litellm_params": { # params for litellm completion/embedding call
- "model": "azure/",
- "api_key": ,
- "api_version": ,
- "api_base":
- },
- },
- {
- "model_name": "azure-gpt-3.5-turbo", # model alias
- "litellm_params": { # params for litellm completion/embedding call
- "model": "azure/",
- "api_key": ,
- "api_version": ,
- "api_base":
- },
- },
- {
- "model_name": "openai-gpt-3.5-turbo", # model alias
- "litellm_params": { # params for litellm completion/embedding call
- "model": "gpt-3.5-turbo",
- "api_key": ,
- },
- ]
-
- router = Router(model_list=model_list, fallbacks=[{"azure-gpt-3.5-turbo": ["openai-gpt-3.5-turbo"]}])
- ```
- """
- self._core_ctx: Final = RouterContext(self)
-
- self.set_verbose = set_verbose
- self.ignore_invalid_deployments = ignore_invalid_deployments
- self.auto_router_capability_limit = auto_router_capability_limit
- self._core_fallback_access_check: Final = fallback_access_check
- self._core_fallback_budget_check: Final = fallback_budget_check
- self.debug_level = debug_level
- self._core_enable_pre_call_checks = enable_pre_call_checks
- self._core_enable_tag_filtering = enable_tag_filtering
- self._core_tag_filtering_match_any = tag_filtering_match_any
- self._core_tag_routing_prefix = tag_routing_prefix
- from litellm._service_logger import ServiceLogging
-
- self.service_logger_obj: ServiceLogging = ServiceLogging()
- litellm.suppress_debug_info = True # prevents 'Give Feedback/Get help' message from being emitted on Router - Relevant Issue: https://github.com/BerriAI/litellm/issues/5942
- if self.set_verbose is True:
- if debug_level == "INFO":
- verbose_router_logger.setLevel(logging.INFO)
- elif debug_level == "DEBUG":
- verbose_router_logger.setLevel(logging.DEBUG)
- self._core_router_general_settings: RouterGeneralSettings = router_general_settings or RouterGeneralSettings()
-
- self.assistants_config = assistants_config
- self._core_search_tools = search_tools or []
- self._core_guardrail_list = guardrail_list or []
- self._core_deployment_names: list[str] = [] # names of models under litellm_params. ex. azure/chatgpt-v-2
- self._core_deployment_latency_map = {}
- ### CACHING ###
- cache_type: Literal["local", "redis", "redis-semantic", "s3"] = "local" # default to an in-memory cache
- redis_cache = None
- cache_config: Final[dict[str, Any]] = {}
-
- self.client_ttl = client_ttl
- self._core_kubernetes_pod_discovery: Final = KubernetesPodDiscovery()
- if redis_url is not None or (redis_host is not None and redis_port is not None):
- cache_type = "redis"
-
- if redis_url is not None:
- cache_config["url"] = redis_url
-
- if redis_host is not None:
- cache_config["host"] = redis_host
-
- if redis_port is not None:
- cache_config["port"] = str(redis_port)
-
- if redis_password is not None:
- cache_config["password"] = redis_password
-
- if redis_db is not None:
- verbose_router_logger.warning(
- "Deprecated 'redis_db' argument used. Please remove 'redis_db' from your config/database and use 'cache_kwargs' instead."
- )
- cache_config["db"] = str(redis_db)
-
- # Add additional key-value pairs from cache_kwargs
- cache_config.update(cache_kwargs)
- redis_cache = self._create_redis_cache(cache_config)
-
- if cache_responses:
- if litellm.cache is None:
- # the cache can be initialized on the proxy server. We should not overwrite it
- litellm.cache = litellm.Cache(type=cache_type, **cache_config)
- self._core_cache_responses = cache_responses
- self._core_cache = DualCache(
- redis_cache=redis_cache, in_memory_cache=InMemoryCache()
- ) # use a dual cache (Redis+In-Memory) for tracking cooldowns, usage, etc.
- self._claude_code_session_router_cache: DualCache = DualCache(
- redis_cache=redis_cache,
- in_memory_cache=InMemoryCache(),
- )
- self._core_prompt_caching_pin_cache: DualCache = DualCache(
- redis_cache=redis_cache,
- in_memory_cache=InMemoryCache(max_size_in_memory=PROMPT_CACHE_PIN_CACHE_MAX_SIZE),
- )
-
- ### SCHEDULER ###
- self.scheduler = Scheduler(polling_interval=polling_interval, redis_cache=redis_cache)
- parsed_default_priority: Final = parse_default_priority(default_priority)
- if isinstance(parsed_default_priority, InvalidPriority):
- verbose_router_logger.warning(
- "router_settings.default_priority is ignored, no request is queued by default: %s",
- parsed_default_priority.message,
- )
- self.default_priority = (
- None if isinstance(parsed_default_priority, InvalidPriority) else parsed_default_priority
- )
- self._core_default_deployment = (
- None # use this to track the users default deployment, when they want to use model = *
- )
- self._core_default_max_parallel_requests = default_max_parallel_requests
- self._core_provider_default_deployment_ids: list[str] = []
- self._core_pattern_router = PatternMatchRouter()
- self._core_team_pattern_routers: dict[str, PatternMatchRouter] = {} # {"TEAM_ID": PatternMatchRouter}
- self._core_auto_routers: dict[str, list[TaggedPreRoutingStrategy[AutoRouter]]] = {}
- self._core_complexity_routers: dict[str, list[TaggedPreRoutingStrategy[ComplexityRouter]]] = {}
- self._core_adaptive_routers: dict[str, list[TaggedPreRoutingStrategy[AdaptiveRouter]]] = {}
- self._core_quality_routers: dict[str, list[TaggedPreRoutingStrategy[QualityRouter]]] = {}
- self._core_routing_plugins: list[RoutingPlugin] = list(plugins) if plugins else []
-
- # Initialize model_group_alias early since it's used in set_model_list
- self._core_model_group_alias: dict[str, str | RouterModelGroupAliasItem] = (
- model_group_alias or {}
- ) # dict to store aliases for router, ex. {"gpt-4": "gpt-3.5-turbo"}, all requests with gpt-4 -> get routed to gpt-3.5-turbo group
-
- # Initialize model ID to deployment index mapping for O(1) lookups
- self.model_id_to_deployment_index_map: dict[str, int] = {}
- # Initialize model name to deployment indices mapping for O(1) lookups
- # Maps model_name -> list of indices in model_list
- self._core_model_name_to_deployment_indices: dict[str, list[int]] = {}
- # Maps (team_id, team_public_model_name) -> list of indices in model_list
- self._core_team_model_to_deployment_indices: dict[tuple[str, str], list[int]] = {}
- self._core_team_public_model_names: frozenset[str] = frozenset()
- # Guards model_list and the three index maps above: writers change them, and readers turn an
- # index into a deployment, under it, so a lookup never lands between a removal and its re-index
- self._deployment_index_lock = threading.RLock()
-
- # Initialize cache attributes that ``_invalidate_model_group_info_cache``
- # and ``_invalidate_access_groups_cache`` touch *before* the first
- # ``set_model_list`` below (which calls those invalidations as part of
- # building the model index) and before ``_init_routing_groups(None)``
- # (which calls them on every group rebuild).
- self._access_groups_cache: dict[str, list[str]] | None = None
- # Per-router cache for the proxy auth-layer "is this model explicitly
- # zero-cost?" check. Lives on the router so it is invalidated alongside
- # ``_cached_get_model_group_info`` and dies with the router (no
- # ``id()``-reuse risk after GC). See
- # ``litellm.proxy.auth.auth_checks._is_model_cost_zero``.
- self._zero_cost_cache: dict[str, bool] = {}
- self._core_cached_deployment_model_info = lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)(
- self._core_get_deployment_model_info
- )
- self._ptu_share_teams = lru_cache(maxsize=1)(self._collect_ptu_share_teams)
- self._cached_team_ptu_ceiling = lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)(self._resolve_team_ptu_ceiling)
- self._cached_deployment_tracks_usage = lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)(
- self._deployment_tracks_usage
- )
- self.served_openai_models = lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)(self._served_openai_models)
- self._discovered_model_info_cache: InMemoryCache = InMemoryCache(
- max_size_in_memory=max(len(model_list or ()), 1),
- default_ttl=2 * MODEL_INFO_REFRESH_SECONDS,
- )
- self._routing_group_rows: tuple[DeploymentTypedDict, ...] | None = None
- self._init_routing_groups(None)
- self._provider_unresolved_deployments: tuple[Callable[[], Deployment | None], ...] = ()
-
- self.deployment_affinity_ttl_seconds = deployment_affinity_ttl_seconds
- self.session_affinity_repin_on_failover = session_affinity_repin_on_failover
- self.model_group_affinity_config = model_group_affinity_config
- warn_on_unknown_model_group_affinity_flags(model_group_affinity_config)
-
- if model_list is not None:
- # set_model_list will build indices automatically
- self.set_model_list(model_list)
- self.healthy_deployments: list = self._core_model_list
- for m in model_list:
- if "model" in m["litellm_params"]:
- self._core_deployment_latency_map[m["litellm_params"]["model"]] = 0
- else:
- self._core_model_list: list = [] # initialize an empty list - to allow _add_deployment and delete_deployment to work
- # Track this router so a price data reload can rebuild its deployments'
- # cost-map entries from the list it is serving at that moment, whether
- # those deployments came from model_list or were added later from the DB.
- _live_routers.add(self)
-
- if allowed_fails is not None:
- self._core_allowed_fails = allowed_fails
- else:
- self._core_allowed_fails = litellm.allowed_fails
- self.explicit_allowed_fails = allowed_fails
- self._core_cooldown_time = cooldown_time or DEFAULT_COOLDOWN_TIME_SECONDS
- self._core_cooldown_cache = CooldownCache(
- cache=self._core_cache, default_cooldown_time=self._core_cooldown_time
- )
- self._core_disable_cooldowns = disable_cooldowns
- self.fallback_on_unsupported_params = fallback_on_unsupported_params
- self._core_enable_health_check_routing = enable_health_check_routing
- self.enable_weighted_failover = enable_weighted_failover
- self.enable_mid_stream_fallback_continuation = self._is_flag_enabled(enable_mid_stream_fallback_continuation)
- self._core_health_check_ignore_transient_errors = health_check_ignore_transient_errors
- self._core_background_health_check_model_groups: frozenset[str] | None = (
- frozenset(background_health_check_model_groups)
- if background_health_check_model_groups is not None
- else None
- )
- _staleness: Final = health_check_staleness_threshold or (
- DEFAULT_HEALTH_CHECK_INTERVAL * DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER
- )
- self._core_health_state_cache = DeploymentHealthCache(
- cache=self._core_cache, staleness_threshold=float(_staleness)
- )
-
- if num_retries is not None:
- self.num_retries = num_retries
- elif litellm.num_retries is not None:
- self.num_retries = litellm.num_retries
- else:
- self.num_retries = openai.DEFAULT_MAX_RETRIES
-
- if max_fallbacks is not None:
- self.max_fallbacks = max_fallbacks
- elif litellm.max_fallbacks is not None:
- self.max_fallbacks = litellm.max_fallbacks
- else:
- self.max_fallbacks = litellm.ROUTER_MAX_FALLBACKS
-
- self._explicit_timeout = timeout # None when user did not pass timeout
- self.timeout = timeout or litellm.request_timeout
- # Per-attempt request_timeout, independent of router_settings.timeout.
- # Only stored when a router timeout is also set, since otherwise
- # request_timeout already flows through self.timeout above.
- self.request_timeout = get_configured_request_timeout() if timeout is not None else None
- self.stream_timeout = stream_timeout
-
- self.retry_after = retry_after
- self._core_routing_strategy = self._normalize_strategy(routing_strategy)
- self._routing_groups_input: list[RoutingGroup | dict] | None = routing_groups
-
- ## SETTING FALLBACKS ##
- ### validate if it's set + in correct format
- _fallbacks = fallbacks or litellm.fallbacks
-
- self.validate_fallbacks(fallback_param=_fallbacks)
- ### set fallbacks
- self._core_fallbacks = _fallbacks
-
- if default_fallbacks is not None or litellm.default_fallbacks is not None:
- _fallbacks = default_fallbacks or litellm.default_fallbacks
- if isinstance(_fallbacks, str):
- raise TypeError(
- f"default_fallbacks must be a list of model names, got '{_fallbacks}'. Use ['{_fallbacks}']"
- )
- if self._core_fallbacks is not None:
- self._core_fallbacks.append({"*": _fallbacks})
- else:
- self._core_fallbacks = [{"*": _fallbacks}]
-
- self._core_context_window_fallbacks = context_window_fallbacks or litellm.context_window_fallbacks
-
- _content_policy_fallbacks: Final = content_policy_fallbacks or litellm.content_policy_fallbacks
- self.validate_fallbacks(fallback_param=_content_policy_fallbacks)
- self._core_content_policy_fallbacks = _content_policy_fallbacks
- self.total_calls: defaultdict = defaultdict(int) # dict to store total calls made to each model
- self.fail_calls: defaultdict = defaultdict(int) # dict to store fail_calls made to each model
- self.success_calls: defaultdict = defaultdict(int) # dict to store success_calls made to each model
-
- # make Router.chat.completions.create compatible for openai.chat.completions.create
- default_litellm_params = default_litellm_params or {}
- self.chat = litellm.Chat(params=default_litellm_params, router_obj=self)
-
- # default litellm args
- self._core_default_litellm_params = default_litellm_params
- self._core_default_litellm_params.setdefault("timeout", timeout)
- self._core_default_litellm_params.setdefault("max_retries", 0)
- self._core_default_litellm_params.setdefault("metadata", {}).update({"caching_groups": caching_groups})
-
- self.deployment_stats: dict = {} # used for debugging load balancing
- """
- deployment_stats = {
- "122999-2828282-277:
- {
- "model": "gpt-3",
- "api_base": "http://localhost:4000",
- "num_requests": 20,
- "avg_latency": 0.001,
- "num_failures": 0,
- "num_successes": 20
- }
- }
- """
-
- ### ROUTING SETUP ###
- if self._normalize_strategy(routing_strategy) == "lar1":
- from litellm.router_strategy.lar1_routing import apply_lar1_routing_strategy
-
- apply_lar1_routing_strategy(self._core_ctx, routing_strategy_args)
- else:
- self.routing_strategy_init(
- routing_strategy=routing_strategy,
- routing_strategy_args=routing_strategy_args,
- )
- self._init_routing_groups(self._routing_groups_input)
- self._override_selectors: dict[str, RouterStrategySelector | None] = {}
- self._override_selectors_lock = threading.Lock()
- self._request_scoped_selectors: tuple[CustomLogger, ...] = ()
- self._request_scoped_policies: tuple[CustomLogger, ...] = ()
- self.access_groups = None
- with _routers_holding_global_callbacks_lock:
- _routers_holding_global_callbacks.add(self)
- ## USAGE TRACKING ##
- litellm.logging_callback_manager.add_litellm_async_success_callback(self.deployment_callback_on_success)
- if isinstance(litellm.success_callback, list):
- litellm.logging_callback_manager.add_litellm_success_callback(self.sync_deployment_callback_on_success)
- else:
- litellm.success_callback = [self.sync_deployment_callback_on_success]
- litellm.logging_callback_manager.add_litellm_async_failure_callback(self.async_deployment_callback_on_failure)
- ## COOLDOWNS ##
- if isinstance(litellm.failure_callback, list):
- litellm.logging_callback_manager.add_litellm_failure_callback(self.deployment_callback_on_failure)
- else:
- litellm.failure_callback = [self.deployment_callback_on_failure]
- self.routing_strategy_args = routing_strategy_args
- self._core_provider_budget_config = provider_budget_config
- self.router_budget_logger: RouterBudgetLimiting | None = None
- if RouterBudgetLimiting.should_init_router_budget_limiter(
- model_list=model_list, provider_budget_config=self._core_provider_budget_config
- ):
- if optional_pre_call_checks is not None:
- optional_pre_call_checks.append("router_budget_limiting")
- else:
- optional_pre_call_checks = ["router_budget_limiting"]
- self.retry_policy: RetryPolicy | None = None
- if retry_policy is not None:
- if isinstance(retry_policy, dict):
- self.retry_policy = RetryPolicy(**retry_policy)
- elif isinstance(retry_policy, RetryPolicy):
- self.retry_policy = retry_policy
- if self.retry_policy is not None:
- verbose_router_logger.info(
- "\x1b[32mRouter Custom Retry Policy Set:\n%s\x1b[0m",
- self.retry_policy.model_dump(exclude_none=True),
- )
-
- self.model_group_retry_policy: dict[str, RetryPolicy] | None = model_group_retry_policy
-
- self._core_allowed_fails_policy: AllowedFailsPolicy | None = None
- if allowed_fails_policy is not None:
- if isinstance(allowed_fails_policy, dict):
- self._core_allowed_fails_policy = AllowedFailsPolicy(**allowed_fails_policy)
- elif isinstance(allowed_fails_policy, AllowedFailsPolicy):
- self._core_allowed_fails_policy = allowed_fails_policy
-
- if self._core_allowed_fails_policy is not None:
- verbose_router_logger.info(
- "\x1b[32mRouter Custom Allowed Fails Policy Set:\n%s\x1b[0m",
- self._core_allowed_fails_policy.model_dump(exclude_none=True),
- )
-
- self.alerting_config: AlertingConfig | None = alerting_config
-
- if optional_pre_call_checks is not None:
- self.add_optional_pre_call_checks(optional_pre_call_checks)
-
- # If model_group_affinity_config is set but no global affinity checks were
- # enabled, we still need the DeploymentAffinityCheck callback (with global
- # flags all False) so per-group config can activate affinity per model group.
- if self.model_group_affinity_config:
- self._ensure_deployment_affinity_callback()
-
- if self.alerting_config is not None:
- self._initialize_alerting()
-
- self.initialize_assistants_endpoint()
- self.initialize_router_endpoints()
- self.apply_default_settings()
-
- @staticmethod
- def get_valid_args() -> list[str]:
- """
- Returns a list of valid arguments for the Router.__init__ method.
- """
- arg_spec: Final = inspect.getfullargspec(Router.__init__)
- valid_args: Final = arg_spec.args + arg_spec.kwonlyargs
- if "self" in valid_args:
- valid_args.remove("self")
- return valid_args
-
- def apply_default_settings(self):
- """
- Apply the default settings to the router.
- """
-
- default_pre_call_checks: Final[OptionalPreCallChecks] = []
- self.add_optional_pre_call_checks(default_pre_call_checks)
-
- if self.enable_mid_stream_fallback_continuation:
- self._register_continuation_prefill_check()
-
- @staticmethod
- def _register_continuation_prefill_check() -> None:
- # Process-global on purpose: Router._core_discard() must not drop a filter another router still needs.
- litellm.logging_callback_manager.add_litellm_callback(ContinuationPrefillDeploymentCheck())
-
- def _core_discard(self) -> None:
- """
- Pseudo-destructor to be invoked to clean up global data structures when router is no longer used.
- For now, unhook router's callbacks from all lists
- """
- # Stop contributing to cost-map rebuilds straight away rather than waiting
- # for this router to be collected.
- _live_routers.discard(self)
- self.retire_routing_state()
- self._release_global_callbacks()
-
- def _release_global_callbacks(self) -> None:
- with _routers_holding_global_callbacks_lock:
- _routers_holding_global_callbacks.discard(self)
- slack_alerting_loggers: Final = self._slack_alerting_loggers()
- adaptive_router_hooks: Final = self._adaptive_router_hooks()
- vacated_keys: Final = litellm.logging_callback_manager.drop_callbacks(
- (
- *self._callback_selectors(),
- *self._override_selectors.values(),
- *(self.optional_callbacks or ()),
- *slack_alerting_loggers,
- *adaptive_router_hooks,
- ),
- method_owners=(self, *slack_alerting_loggers),
- )
- with _routers_holding_global_callbacks_lock:
- survivors: Final = tuple(_routers_holding_global_callbacks)
- candidates: Final = chain.from_iterable(router._global_callback_candidates() for router in survivors)
- for candidate in litellm.logging_callback_manager.with_custom_logger_keys(candidates, vacated_keys):
- self._register_router_selector(candidate)
- adaptive_keys: Final = frozenset(
- map(litellm.logging_callback_manager.get_custom_logger_key, adaptive_router_hooks)
- )
- adaptive_survivor: Final = next((router for router in survivors if router._core_adaptive_routers), None)
- if adaptive_survivor is not None and vacated_keys & adaptive_keys:
- adaptive_survivor._sync_adaptive_router_hooks()
-
- def _core_serve_through_request_logging(self) -> None:
- self._request_scoped_selectors = (*self._callback_selectors(), *self._request_adaptive_router_hooks())
- self._request_scoped_policies = (*self._optional_loggers(), *self._slack_alerting_loggers())
- self._release_global_callbacks()
-
- def _core_bind_to_request_logging(self, logging_obj: LiteLLMLogging) -> None:
- logging_obj.add_dynamic_function_callbacks(
- async_success=self.deployment_callback_on_success,
- failure=self.deployment_callback_on_failure,
- async_failure=self.async_deployment_callback_on_failure,
- )
- for selector in self._request_scoped_selectors:
- logging_obj.replace_global_callback(selector)
- for policy in self._request_policies_not_set_globally():
- logging_obj.add_dynamic_callback(policy)
-
- def _request_policies_not_set_globally(self) -> tuple[CustomLogger, ...]:
- global_keys: Final = frozenset(
- map(
- litellm.logging_callback_manager.get_custom_logger_key,
- (callback for callback in litellm.callbacks if isinstance(callback, CustomLogger)),
- )
- )
- return tuple(
- policy
- for policy in self._request_scoped_policies
- if litellm.logging_callback_manager.get_custom_logger_key(policy) not in global_keys
- )
-
- def _request_adaptive_router_hooks(self) -> tuple[CustomLogger, ...]:
- from litellm.router_strategy.adaptive_router.hooks import (
- AdaptiveRouterPostCallHook,
- )
-
- return tuple(
- AdaptiveRouterPostCallHook(adaptive_router=tagged.strategy)
- for tagged in chain.from_iterable(self._core_adaptive_routers.values())
- )
-
- def _routing_callbacks(self) -> Iterable[object]:
- if not (self._request_scoped_selectors or self._request_scoped_policies):
- return litellm.callbacks
- replaced_keys: Final = frozenset(
- map(litellm.logging_callback_manager.get_custom_logger_key, self._request_scoped_selectors)
- )
- return chain(
- litellm.logging_callback_manager.without_custom_logger_keys(litellm.callbacks, replaced_keys),
- self._request_scoped_selectors,
- self._request_policies_not_set_globally(),
- )
-
- def retire_routing_state(self) -> None:
- for strategy in (*self._global_callback_candidates(), *self._override_selectors.values()):
- if isinstance(strategy, BaseRoutingStrategy | RouterBudgetLimiting):
- strategy.retire()
-
- def _slack_alerting_loggers(self) -> tuple[CustomLogger, ...]:
- slack_alerting_logger: Final = getattr(self, "_core_slack_alerting_logger", None)
- return (slack_alerting_logger,) if isinstance(slack_alerting_logger, CustomLogger) else ()
-
- def _adaptive_router_hooks(self) -> tuple[CustomLogger, ...]:
- from litellm.router_strategy.adaptive_router.hooks import (
- AdaptiveRouterPostCallHook,
- )
-
- own_strategy_ids: Final = frozenset(
- id(tagged.strategy) for tagged in chain.from_iterable(self._core_adaptive_routers.values())
- )
- hooks: Final = litellm.logging_callback_manager.get_custom_loggers_for_type(AdaptiveRouterPostCallHook)
- return tuple(
- hook
- for hook in hooks
- if isinstance(hook, AdaptiveRouterPostCallHook) and id(hook.adaptive_router) in own_strategy_ids
- )
-
- def _optional_loggers(self) -> tuple[CustomLogger, ...]:
- return tuple(callback for callback in self.optional_callbacks or () if isinstance(callback, CustomLogger))
-
- def _global_callback_candidates(self) -> tuple[CustomLogger, ...]:
- return (*self._callback_selectors(), *self._optional_loggers(), *self._slack_alerting_loggers())
-
- @staticmethod
- def _create_redis_cache(
- cache_config: dict[str, Any],
- ) -> RedisCache | RedisClusterCache:
- """
- Initializes either a RedisCache or RedisClusterCache based on the cache_config.
- """
- startup_nodes = cache_config.get("startup_nodes")
- if not startup_nodes:
- _env_cluster_nodes: Final = get_secret("REDIS_CLUSTER_NODES")
- if _env_cluster_nodes is not None and isinstance(_env_cluster_nodes, str):
- startup_nodes = _OPAQUE_VALUE_ADAPTER.validate_python(json.loads(_env_cluster_nodes))
-
- if startup_nodes:
- return RedisClusterCache(**{**cache_config, "startup_nodes": startup_nodes})
- else:
- return RedisCache(**cache_config)
-
- def _core_update_redis_cache(self, cache: RedisCache) -> None:
- """
- Update the redis cache for the router, if none set.
-
- Allows proxy user to just do
- ```yaml
- litellm_settings:
- cache: true
- ```
- and caching to just work.
- """
- self._core_cache.attach_redis_cache(cache)
- self._claude_code_session_router_cache.attach_redis_cache(cache)
- self._core_prompt_caching_pin_cache.attach_redis_cache(cache)
-
- # Maps a routing strategy string to the attribute on `self` that holds
- # the default group's strategy selector for that strategy. (The selectors
- # double as `CustomLogger` callbacks, hence the legacy `*_logger` attrs.)
- _DEFAULT_SELECTOR_ATTR_BY_STRATEGY: dict[str, str] = {
- "least-busy": "leastbusy_logger",
- "usage-based-routing": "lowesttpm_logger",
- "usage-based-routing-v2": "lowesttpm_logger_v2",
- "latency-based-routing": "lowestlatency_logger",
- "cost-based-routing": "lowestcost_logger",
- }
-
- @staticmethod
- def _normalize_strategy(
- strategy: RoutingStrategy | str | None,
- ) -> str | None:
- if strategy is None:
- return None
- if isinstance(strategy, RoutingStrategy):
- return strategy.value
- return strategy
-
- @staticmethod
- def _validate_routing_strategy(routing_strategy: RoutingStrategy | str | None) -> None:
- validate_routing_strategy(routing_strategy)
-
- def _build_strategy_selector(
- self,
- strategy: RoutingStrategy | str,
- routing_strategy_args: dict,
- register_callbacks: bool = True,
- ) -> RouterStrategySelector | None:
- """
- Constructs a strategy selector for a given strategy.
- Returns None for `simple-shuffle` (no selector needed) and unknown
- strategies.
- """
- selector: RouterStrategySelector | None = None
- match self._normalize_strategy(strategy):
- case RoutingStrategy.LEAST_BUSY.value:
- selector = LeastBusyLoggingHandler(router_cache=self._core_cache)
- case RoutingStrategy.USAGE_BASED_ROUTING.value:
- selector = LowestTPMLoggingHandler(
- router_cache=self._core_cache,
- routing_args=routing_strategy_args,
- )
- case RoutingStrategy.USAGE_BASED_ROUTING_V2.value:
- selector = LowestTPMLoggingHandler_v2(
- router_cache=self._core_cache,
- routing_args=routing_strategy_args,
- )
- case RoutingStrategy.LATENCY_BASED.value:
- selector = LowestLatencyLoggingHandler(
- router_cache=self._core_cache,
- routing_args=routing_strategy_args,
- )
- case RoutingStrategy.COST_BASED.value:
- selector = LowestCostLoggingHandler(
- router_cache=self._core_cache,
- routing_args={},
- )
- case _:
- pass
-
- if selector is not None and register_callbacks:
- self._register_router_selector(selector)
-
- return selector
-
- @staticmethod
- def _register_router_selector(selector: CustomLogger) -> None:
- if isinstance(selector, LeastBusyLoggingHandler):
- if isinstance(litellm.input_callback, list):
- litellm.logging_callback_manager.add_litellm_input_callback(selector)
- else:
- litellm.input_callback = [selector]
- if isinstance(litellm.callbacks, list):
- litellm.logging_callback_manager.add_litellm_callback(selector)
-
- def _callback_selectors(self) -> tuple[RouterStrategySelector, ...]:
- return tuple(
- selector
- for selector in (
- *(getattr(self, attr, None) for attr in self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.values()),
- *chain.from_iterable(selectors.values() for selectors in self._group_selectors.values()),
- )
- if selector is not None
- )
-
- def _unregister_router_selectors(self, selectors: Sequence[object]) -> None:
- """
- Drop router-owned strategy selectors from litellm's global callback
- lists. Used before re-init (`routing_strategy_init` /
- `_init_routing_groups`) and by `discard` so dead selectors stop
- receiving callback events. `function_setup` copies `litellm.callbacks`
- into the per-event lists on every request, so those are purged too.
- """
- for selector in selectors:
- if isinstance(selector, BaseRoutingStrategy):
- selector.retire()
- litellm.logging_callback_manager.drop_callbacks(selectors)
-
- def _apply_updated_routing_strategy_args(self) -> None:
- """
- Re-link the default group's selector to the current `routing_strategy_args`.
-
- Selectors freeze their `RoutingArgs` at construction, so a runtime args
- update would otherwise keep serving the boot-time values until restart.
- Latency/usage state survives the rebuild: it lives in the shared router
- cache, not on the selector.
- """
- strategy: Final = self._normalize_strategy(self._core_routing_strategy)
- if strategy == "lar1":
- from litellm.router_strategy.lar1_routing import apply_lar1_routing_strategy
-
- apply_lar1_routing_strategy(self._core_ctx, self.routing_strategy_args)
- return
-
- attr: Final = self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.get(strategy or "")
- current: Final = getattr(self, attr, None) if attr is not None else None
- if attr is None or current is None:
- return
-
- try:
- rebuilt: Final = self._build_strategy_selector(
- strategy=strategy or "",
- routing_strategy_args=self.routing_strategy_args,
- )
- except (TypeError, ValidationError):
- verbose_router_logger.exception(
- "Invalid routing_strategy_args %s for '%s'; keeping the previous ones",
- self.routing_strategy_args,
- strategy,
- )
- return
-
- self._unregister_router_selectors((current,))
- setattr(self, attr, rebuilt)
-
- def routing_strategy_init(self, routing_strategy: RoutingStrategy | str, routing_strategy_args: dict):
- verbose_router_logger.info("Routing strategy: %s", routing_strategy)
- self._validate_routing_strategy(routing_strategy)
- self._reset_custom_routing_strategy()
-
- self._unregister_router_selectors(
- [getattr(self, attr, None) for attr in self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.values()]
- + list(getattr(self, "_override_selectors", {}).values())
- )
- self._override_selectors = {}
-
- self.leastbusy_logger: LeastBusyLoggingHandler | None = None
- self.lowesttpm_logger: LowestTPMLoggingHandler | None = None
- self.lowesttpm_logger_v2: LowestTPMLoggingHandler_v2 | None = None
- self.lowestlatency_logger: LowestLatencyLoggingHandler | None = None
- self.lowestcost_logger: LowestCostLoggingHandler | None = None
-
- selector: Final = self._build_strategy_selector(
- strategy=routing_strategy,
- routing_strategy_args=routing_strategy_args,
- )
- attr: Final = self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.get(self._normalize_strategy(routing_strategy) or "")
- # TODO: legacy `self._logger` attributes are read directly by
- # `get_settings()` and external callers. Fold the default group into
- # `self._group_selectors["default"]` and drop these attribute writes —
- # the dual storage is an antipattern preserved only for back-compat.
- if attr is not None:
- setattr(self, attr, selector)
-
- def _init_routing_groups(
- self,
- groups_input: list[RoutingGroup | dict] | None,
- ) -> None:
- """
- Validates and indexes `routing_groups`. Each `model_name` may belong to
- at most one non-priority group. Constructs per-group strategy selectors so
- groups with different `routing_strategy_args` track independent state.
-
- Models not claimed by a non-priority group are served by the implicit
- `"default"` group, whose selectors are the `self._logger`
- attributes set up in `routing_strategy_init`.
- """
- if not groups_input:
- self._replace_routing_groups(())
- return
-
- known_model_names: Final = frozenset(
- m["model_name"] for m in (self._core_model_list or ()) if m.get("model_name")
- )
- groups: Final = parse_routing_groups(groups_input, known_model_names=known_model_names)
-
- alias_names: Final = frozenset(self._core_model_group_alias or ())
- for group in groups:
- if group.group_name in known_model_names or group.group_name in alias_names:
- if group.routing_strategy == "priority":
- raise ValueError("Priority routing group names must not shadow a model or alias")
- verbose_router_logger.warning(
- "routing_groups: group_name '%s' is shadowed by an existing model_name or model_group_alias; "
- "the group's strategy still applies to its members, but the name is not callable until renamed.",
- group.group_name,
- )
-
- built: Final = tuple(
- (
- group,
- self._build_strategy_selector(
- strategy="simple-shuffle" if group.routing_strategy == "priority" else group.routing_strategy,
- routing_strategy_args=group.routing_strategy_args or {},
- register_callbacks=False,
+ def _with_class_cell(value: object, cell: "CellType") -> object:
+ """``value`` with its ``__class__`` cell (what zero-argument ``super()`` reads) replaced by ``cell``."""
+ if isinstance(value, staticmethod | classmethod):
+ return type(value)(_function_with_class_cell(value.__func__, cell))
+ if isinstance(value, property):
+ return property(
+ *(
+ None if f is None else _function_with_class_cell(f, cell)
+ for f in (value.fget, value.fset, value.fdel)
),
+ value.__doc__,
)
- for group in groups
- )
- self._replace_routing_groups(built)
+ return _function_with_class_cell(value, cell)
- def _replace_routing_groups(
- self,
- built: tuple[tuple[RoutingGroup, RouterStrategySelector | None], ...],
- ) -> None:
- previous_selectors: Final[Mapping[str, Mapping[str, RouterStrategySelector]]] = getattr(
- self, "_group_selectors", {}
+ def _function_with_class_cell(value: object, cell: "CellType") -> object:
+ """A plain function with its ``__class__`` cell replaced by ``cell``; anything else unchanged."""
+ if not isinstance(value, FunctionType) or "__class__" not in value.__code__.co_freevars:
+ return value
+ closure: Final = tuple(
+ cell if name == "__class__" else original
+ for name, original in zip(value.__code__.co_freevars, value.__closure__ or (), strict=True)
)
- self._unregister_router_selectors(
- tuple(sel for selectors in previous_selectors.values() for sel in selectors.values())
- )
- for _, selector in built:
- if selector is not None:
- self._register_router_selector(selector)
+ rebound: Final = FunctionType(value.__code__, value.__globals__, value.__name__, value.__defaults__, closure)
+ rebound.__kwdefaults__ = value.__kwdefaults__
+ rebound.__qualname__ = value.__qualname__
+ rebound.__dict__.update(value.__dict__)
+ return rebound
- self._core_routing_groups: dict[str, RoutingGroup] = {group.group_name: group for group, _ in built}
- self._model_to_group: dict[str, str] = {
- model_name: group.group_name
- for group, _ in built
- if group.routing_strategy != "priority"
- for model_name in group.models
+ class _Absent:
+ """A facade-only member, hidden from the core class: reading it raises the usual AttributeError."""
+
+ def __set_name__(self, owner: type, name: str) -> None:
+ self._name = name
+
+ def __get__(self, instance: object, owner: type | None = None) -> object:
+ if instance is None:
+ raise AttributeError(
+ f"type object {getattr(owner, '__name__', owner)!r} has no attribute {self._name!r}"
+ )
+ raise AttributeError(f"{type(instance).__name__!r} object has no attribute {self._name!r}")
+
+ def _core_class_for(facade: type, parent: type) -> type:
+ """
+ A core class carrying a Router subclass's own members, so the core runs its overrides. It
+ subclasses the facade too, so an override's ``super(Sub, self)`` finds the core it runs on.
+ """
+ cell: Final = CellType()
+ members: Final = {
+ name: _with_class_cell(value, cell) for name, value in vars(facade).items() if name not in _FACADE_ONLY
}
- self._group_selectors: dict[str, dict[str, RouterStrategySelector]] = {
- group.group_name: (
- {} if selector is None else {self._normalize_strategy(group.routing_strategy) or "": selector}
- )
- for group, selector in built
+ namespace: Final = {
+ # The facade's own plumbing sits in the core class's MRO ahead of the parent core: put
+ # the parent core's versions back, and hide the facade-only members.
+ "__init__": parent.__init__,
+ "__getattribute__": object.__getattribute__,
+ "__setattr__": parent.__setattr__,
+ "__delattr__": parent.__delattr__,
+ "__dir__": parent.__dir__,
+ **{name: _Absent() for name in ("_attach", "_core", "from_config", "_core_class")},
+ **members,
+ "__module__": facade.__module__,
}
- self._invalidate_model_group_info_cache()
- self._invalidate_access_groups_cache()
+ core_class: Final = _CoreType(f"{facade.__name__}Core", (facade, parent), namespace)
+ cell.cell_contents = core_class
+ for name in members:
+ type.__delattr__(facade, name)
+ return core_class
- def get_routing_group(self, model_name: str) -> RoutingGroup | None:
+ class _RouterType(type):
"""
- The routing group callable as `model_name`, or None. A real deployment
- `model_name` added after init shadows a same-named group (mirroring
- `_try_early_resolve_deployments_for_model_not_in_names`, where concrete
- models win over indirection); config-time collisions are rejected by
- `_init_routing_groups`.
+ Class-level reads of core members (``Router._helper(...)``) go to the router's core class, and
+ so do class-level patches, which put the original back when the patch is removed.
+ ``isinstance`` and ``dir`` see the core class too.
"""
- if not self._core_routing_groups:
- return None
- group: Final = self._core_routing_groups.get(model_name)
- if (
- group is None
- or model_name in self._core_model_name_to_deployment_indices
- or model_name in (self._core_model_group_alias or {})
- ):
- return None
- return group
- def _get_routing_group_deployments(
- self, model: str, team_id: str | None = None
- ) -> list[DeploymentTypedDict] | None: # mutable-ok: list matches _get_all_deployments' contract for callers
- """
- The union of member deployments for a routing group called as `model`,
- or None when `model` is not a callable group. The requested name stays
- the group name so strategy selectors key their state by it.
+ def __getattr__(cls, name: str) -> object:
+ if name == "_core_class":
+ raise AttributeError(name)
+ return getattr(cls._core_class, name)
- `_common_checks_available_deployment` consults this BEFORE its
- early-resolve step so a wildcard `default_deployment` or pattern route
- cannot hijack a group call. Overall resolution precedence there:
- specific deployment > model id > model_group_alias > routing group >
- model_name > team/pattern/default fallbacks.
- """
- if not self._core_routing_groups:
- return None
- routing_group: Final = self.get_routing_group(model)
- if routing_group is None:
- return None
- return [
- apply_routing_group_priority(routing_group, member, deployment)
- for member in routing_group.models
- for deployment in self._core_get_all_deployments(model_name=member, team_id=team_id)
- ]
-
- def _is_priority_routing_group(self, model: str) -> bool:
- resolved: Final = self._core_get_model_from_alias(model=model) or model
- group: Final = self.get_routing_group(resolved)
- return group is not None and group.routing_strategy == "priority"
-
- def _core_is_recognized_model(self, model: str) -> bool:
- """
- Whether `model` names something this router serves directly: a
- deployment model_name, a deployment id, a `model_group_alias`, or a
- callable routing group. Proxy request gates share this predicate so a
- new virtual-model kind cannot be forgotten at one of them; wildcard,
- default-deployment, and deployment-name fallbacks stay caller policy.
- """
- return (
- model in self._core_model_names
- or self._core_has_model_id(model)
- or (self._core_model_group_alias is not None and model in self._core_model_group_alias)
- or self.get_routing_group(model) is not None
- )
-
- def _core_routing_group_has_alternatives(self, model_group: str | None) -> bool:
- """
- True when `model_group` names a callable routing group whose member
- union spans more than one deployment. Cooldown handling passes the
- FAILING REQUEST's model group here: a 429 on a group call cools the
- member down so selection moves to the group's alternatives, while a
- direct call to a single-deployment member keeps the
- single-deployment-model-group cooldown exemption.
- """
- if model_group is None:
- return False
- resolved: Final = self._core_get_model_from_alias(model=model_group) or model_group
- group: Final = self.get_routing_group(resolved)
- if group is None:
- return False
- return sum(len(self._core_model_name_to_deployment_indices.get(member) or ()) for member in group.models) > 1
-
- def _core_team_model_has_alternatives(self, deployment_id: str) -> bool:
- deployment: Final = self._core_get_deployment(model_id=deployment_id)
- if deployment is None:
- return False
- team_id: Final = deployment.model_info.team_id
- public_model_name: Final = deployment.model_info.team_public_model_name
- if team_id is None or public_model_name is None:
- return False
- siblings: Final = self._team_deployments_named(team_id, public_model_name) or ()
- routable_siblings: Final = self._filter_blocked_deployments([dict(sibling) for sibling in siblings])
- return len(routable_siblings) > 1
-
- _OVERRIDABLE_ROUTING_STRATEGIES: frozenset[str] = frozenset({"simple-shuffle", *_DEFAULT_SELECTOR_ATTR_BY_STRATEGY})
-
- def _get_request_routing_strategy_override(self, request_kwargs: dict | None) -> str | None:
- """
- Reads a per-request `routing_strategy` override (forwarded by the proxy
- from key/team `router_settings`) out of the request kwargs.
-
- Only strategies with a per-request-capable selector are honored;
- anything else (unknown strings, `lar1`, `provider-budget-routing`) is
- ignored with a warning so a bad value stored on a key or team can
- never take down that caller's traffic.
- """
- if not request_kwargs:
- return None
- raw_strategy: Final = request_kwargs.get("routing_strategy")
- if raw_strategy is None:
- return None
- strategy = self._normalize_strategy(raw_strategy) if isinstance(raw_strategy, (str, RoutingStrategy)) else None
- if not isinstance(strategy, str) or strategy not in self._OVERRIDABLE_ROUTING_STRATEGIES:
- verbose_router_logger.warning(
- "Ignoring per-request routing_strategy override '%s'; supported overrides: %s.",
- raw_strategy,
- sorted(self._OVERRIDABLE_ROUTING_STRATEGIES),
- )
- return None
- return strategy
-
- def _get_override_strategy_selector(self, strategy: str) -> RouterStrategySelector | None:
- """
- Returns the selector for a per-request strategy override.
-
- Reuses the default group's selector when the override matches the
- router's configured strategy (so shared state keeps accumulating in
- one place); otherwise lazily builds one selector per strategy and
- caches it for the router's lifetime so its usage/latency state
- persists across requests.
- """
- if strategy == self._normalize_strategy(self._core_routing_strategy):
- attr: Final = self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.get(strategy)
- return getattr(self, attr, None) if attr is not None else None
- with self._override_selectors_lock:
- if strategy not in self._override_selectors:
- self._override_selectors[strategy] = self._build_strategy_selector(
- strategy=strategy,
- routing_strategy_args={},
- register_callbacks=False,
- )
- return self._override_selectors[strategy]
-
- def _override_selector_pre_call_check(
- self, strategy: str | None, selector: RouterStrategySelector | None, deployment: dict
- ) -> None:
- """
- Override selectors are not in `litellm.callbacks`, so the pre-call check that
- `routing_strategy_pre_call_checks` runs for the router's own selectors (rpm
- accounting for `usage-based-routing-v2`) runs here, for the overriding request only.
- """
- if selector is None or strategy is None or selector is not self._override_selectors.get(strategy):
- return
- selector.pre_call_check(deployment)
-
- async def _async_override_selector_pre_call_check(
- self,
- strategy: str | None,
- selector: RouterStrategySelector | None,
- deployment: dict,
- parent_otel_span: Span | None,
- ) -> None:
- if selector is None or strategy is None or selector is not self._override_selectors.get(strategy):
- return
- await selector.async_pre_call_check(deployment, parent_otel_span)
-
- def _bind_override_selector_to_request(
- self, strategy: str, selector: RouterStrategySelector | None, request_kwargs: Mapping[str, object] | None
- ) -> None:
- if selector is None or request_kwargs is None or strategy in self._globally_registered_strategies():
- return
- logging_obj: Final = request_kwargs.get("litellm_logging_obj")
- if isinstance(logging_obj, LiteLLMLogging):
- logging_obj.add_dynamic_callback(selector)
-
- def _globally_registered_strategies(self) -> frozenset[str]:
- configured: Final = (
- self._core_routing_strategy,
- *(group.routing_strategy for group in self._core_routing_groups.values()),
- )
- return frozenset(
- normalized for normalized in map(self._normalize_strategy, configured) if normalized is not None
- )
-
- def _core_arm_routing_read_prefetch(self, model: str, request_kwargs: dict[str, object] | None = None) -> None:
- """Declare the cooldown read (and, for usage-based routing, the usage read) that
- `async_get_available_deployment` will make for `model` on the request's Redis batch, so admission's
- flush carries it. A miss (alias, no batch) costs nothing: routing then reads as it always has."""
- try:
- strategy, selector = self._get_routing_context(model, request_kwargs)
- usage_selector: Final = (
- selector
- if strategy == "usage-based-routing-v2" and isinstance(selector, LowestTPMLoggingHandler_v2)
- else None
- )
- deployments: Final = self._core_get_model_list(model_name=model)
- if deployments:
- RoutingPrefetch.arm(self._core_ctx, usage_selector, deployments)
- except Exception as e: # noqa: BLE001 # a prefetch is an optimisation, never a reason to fail the request
- verbose_router_logger.debug(
- "routing read prefetch not armed for %s: %s", _without_line_breaks(model), _without_line_breaks(e)
- )
-
- def _get_routing_context(
- self, model: str, request_kwargs: dict | None = None
- ) -> tuple[str | None, RouterStrategySelector | None]:
- """
- Resolves the routing strategy and selector to use for the given model.
-
- A per-request `routing_strategy` in `request_kwargs` (forwarded by the
- proxy from key/team `router_settings`) takes precedence over both the
- model's routing group and the router's top-level strategy, since it is
- the most specific expression of caller intent.
-
- Otherwise every model belongs to exactly one group: an explicit entry
- from `routing_groups` (either because `model` IS a callable group name,
- or because it is a member of one), or the implicit `"default"` group
- driven by the router's top-level `routing_strategy` /
- `routing_strategy_args`.
-
- `self._core_routing_strategy` may be either a string or a `RoutingStrategy`
- enum member (the constructor accepts both), so it is normalized to a
- string here. Downstream call sites and `_select_deployment_*` arms
- compare against string literals.
- """
- override: Final = self._get_request_routing_strategy_override(request_kwargs)
- if override is not None:
- verbose_router_logger.debug("routing_group=request-override model=%s strategy=%s", model, override)
- override_selector: Final = self._get_override_strategy_selector(override)
- self._bind_override_selector_to_request(override, override_selector, request_kwargs)
- return override, override_selector
-
- resolved_model: Final = self._core_get_model_from_alias(model=model) or model
- group_name: Final = (
- resolved_model
- if self.get_routing_group(resolved_model) is not None
- else self._model_to_group.get(resolved_model)
- )
- if group_name is None:
- strategy = self._normalize_strategy(self._core_routing_strategy)
- attr: Final = self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.get(strategy or "")
- selector = getattr(self, attr, None) if attr is not None else None
- verbose_router_logger.debug("routing_group=default model=%s strategy=%s", model, strategy)
- return strategy, selector
-
- group: Final = self._core_routing_groups[group_name]
- if group.routing_strategy == "priority":
- return "simple-shuffle", None
- strategy = self._normalize_strategy(group.routing_strategy)
- selector = self._group_selectors.get(group_name, {}).get(strategy or "")
- verbose_router_logger.debug("routing_group=%s model=%s strategy=%s", group_name, model, strategy)
- return strategy, selector
-
- async def _select_deployment_async(
- self,
- *,
- strategy: str | None,
- selector: Any | None,
- model: str,
- healthy_deployments: list,
- messages: list[dict[str, str]] | None,
- input: str | list | None,
- request_kwargs: dict | None,
- ) -> Any | None:
- """
- Asks the strategy selector for a deployment. Caller handles
- `simple-shuffle` separately (it does not flow through a selector).
- Returns None for unknown strategies or when the selector is missing.
- """
- if selector is None:
- return None
- match strategy:
- case "least-busy":
- return await selector.async_get_available_deployments(
- model_group=model,
- healthy_deployments=healthy_deployments,
- )
- case "usage-based-routing":
- # `LowestTPMLoggingHandler` (v1) only exposes the sync
- # `get_available_deployments`. Mirror the pre-routing-groups
- # top-level fallback by calling it inline so groups using v1
- # still work from async callers.
- return selector.get_available_deployments(
- model_group=model,
- healthy_deployments=healthy_deployments,
- messages=messages,
- input=input,
- )
- case "usage-based-routing-v2" | "cost-based-routing":
- return await selector.async_get_available_deployments(
- model_group=model,
- healthy_deployments=healthy_deployments,
- messages=messages,
- input=input,
- )
- case "latency-based-routing":
- return await selector.async_get_available_deployments(
- model_group=model,
- healthy_deployments=healthy_deployments,
- messages=messages,
- input=input,
- request_kwargs=request_kwargs,
- )
- case _:
- return None
-
- def _select_deployment_sync(
- self,
- *,
- strategy: str | None,
- selector: Any | None,
- model: str,
- healthy_deployments: list,
- messages: list[dict[str, str]] | None,
- input: str | list | None,
- request_kwargs: dict | None,
- ) -> Any | None:
- """
- Sync sibling of `_select_deployment_async`. Caller handles
- `simple-shuffle` separately.
- """
- if selector is None:
- return None
-
- # `cost-based-routing` is intentionally omitted —
- # `LowestCostLoggingHandler` only implements
- # `async_get_available_deployments`
- match strategy:
- case "least-busy":
- return selector.get_available_deployments(
- model_group=model,
- healthy_deployments=healthy_deployments,
- )
- case "usage-based-routing" | "usage-based-routing-v2":
- return selector.get_available_deployments(
- model_group=model,
- healthy_deployments=healthy_deployments,
- messages=messages,
- input=input,
- )
- case "latency-based-routing":
- return selector.get_available_deployments(
- model_group=model,
- healthy_deployments=healthy_deployments,
- messages=messages,
- input=input,
- request_kwargs=request_kwargs,
- )
- case _:
- return None
-
- def initialize_assistants_endpoint(self):
- ## INITIALIZE PASS THROUGH ASSISTANTS ENDPOINT ##
- self.acreate_assistants = self.factory_function(litellm.acreate_assistants)
- self.adelete_assistant = self.factory_function(litellm.adelete_assistant)
- self.aget_assistants = self.factory_function(litellm.aget_assistants)
- self.acreate_thread = self.factory_function(litellm.acreate_thread)
- self.aget_thread = self.factory_function(litellm.aget_thread)
- self.a_add_message = self.factory_function(litellm.a_add_message)
- self.aget_messages = self.factory_function(litellm.aget_messages)
- self.arun_thread = self.factory_function(litellm.arun_thread)
-
- def _initialize_core_endpoints(self):
- """Helper to initialize core router endpoints."""
- self.amoderation = self.factory_function(litellm.amoderation, call_type="moderation")
- self.aanthropic_messages = self.factory_function(litellm.anthropic_messages, call_type="anthropic_messages")
- self.anthropic_messages = self.factory_function(litellm.anthropic_messages, call_type="anthropic_messages")
- self.agenerate_content = self.factory_function(litellm.agenerate_content, call_type="agenerate_content")
- self.aadapter_generate_content = self.factory_function(
- litellm.aadapter_generate_content, call_type="aadapter_generate_content"
- )
- self.aresponses = self.factory_function(litellm.aresponses, call_type="aresponses")
- self.afile_delete = self.factory_function(litellm.afile_delete, call_type="afile_delete")
- self.afile_content = self.factory_function(litellm.afile_content, call_type="afile_content")
- self.responses = self.factory_function(litellm.responses, call_type="responses")
- self.aget_responses = self.factory_function(litellm.aget_responses, call_type="aget_responses")
- self.acancel_responses = self.factory_function(litellm.acancel_responses, call_type="acancel_responses")
- self.acompact_responses = self.factory_function(litellm.acompact_responses, call_type="acompact_responses")
- self.adelete_responses = self.factory_function(litellm.adelete_responses, call_type="adelete_responses")
- self.alist_input_items = self.factory_function(litellm.alist_input_items, call_type="alist_input_items")
- self._arealtime = self.factory_function(litellm._arealtime, call_type="_arealtime")
- self.acreate_realtime_client_secret = self.factory_function(
- litellm.acreate_realtime_client_secret, call_type="acreate_realtime_client_secret"
- )
- self.arealtime_calls = self.factory_function(litellm.arealtime_calls, call_type="arealtime_calls")
- self.acreate_realtime_transcription_session = self.factory_function(
- litellm.acreate_realtime_transcription_session, call_type="acreate_realtime_transcription_session"
- )
- self._aresponses_websocket = self.factory_function(
- litellm._aresponses_websocket, call_type="_aresponses_websocket"
- )
- self.acreate_fine_tuning_job = self.factory_function(
- litellm.acreate_fine_tuning_job, call_type="acreate_fine_tuning_job"
- )
- self.acancel_fine_tuning_job = self.factory_function(
- litellm.acancel_fine_tuning_job, call_type="acancel_fine_tuning_job"
- )
- self.alist_fine_tuning_jobs = self.factory_function(
- litellm.alist_fine_tuning_jobs, call_type="alist_fine_tuning_jobs"
- )
- self.aretrieve_fine_tuning_job = self.factory_function(
- litellm.aretrieve_fine_tuning_job, call_type="aretrieve_fine_tuning_job"
- )
- self.afile_list = self.factory_function(litellm.afile_list, call_type="alist_files")
- self.aimage_edit = self.factory_function(litellm.aimage_edit, call_type="aimage_edit")
- self.allm_passthrough_route = self.factory_function(
- litellm.allm_passthrough_route, call_type="allm_passthrough_route"
- )
- # Note: acancel_batch is defined as a method on the Router class (not using factory_function)
- # to properly handle model-to-provider mapping like acreate_batch and aretrieve_batch
-
- def _initialize_vector_store_endpoints(self):
- """Initialize vector store endpoints."""
- from litellm.vector_stores.main import (
- adelete,
- alist,
- aretrieve,
- asearch,
- aupdate,
- create,
- delete,
- list,
- retrieve,
- search,
- update,
- )
-
- self.avector_store_search = self.factory_function(asearch, call_type="avector_store_search")
- self.vector_store_search = self.factory_function(search, call_type="vector_store_search")
- self.vector_store_create = self.factory_function(create, call_type="vector_store_create")
- self.avector_store_retrieve = self.factory_function(aretrieve, call_type="avector_store_retrieve")
- self.vector_store_retrieve = self.factory_function(retrieve, call_type="vector_store_retrieve")
- self.avector_store_list = self.factory_function(alist, call_type="avector_store_list")
- self.vector_store_list = self.factory_function(list, call_type="vector_store_list")
- self.avector_store_update = self.factory_function(aupdate, call_type="avector_store_update")
- self.vector_store_update = self.factory_function(update, call_type="vector_store_update")
- self.avector_store_delete = self.factory_function(adelete, call_type="avector_store_delete")
- self.vector_store_delete = self.factory_function(delete, call_type="vector_store_delete")
-
- def _initialize_vector_store_file_endpoints(self):
- """Initialize vector store file endpoints."""
- from litellm.vector_store_files.main import (
- acreate as avector_store_file_create_fn,
- )
- from litellm.vector_store_files.main import (
- adelete as avector_store_file_delete_fn,
- )
- from litellm.vector_store_files.main import alist as avector_store_file_list_fn
- from litellm.vector_store_files.main import (
- aretrieve as avector_store_file_retrieve_fn,
- )
- from litellm.vector_store_files.main import (
- aretrieve_content as avector_store_file_content_fn,
- )
- from litellm.vector_store_files.main import (
- aupdate as avector_store_file_update_fn,
- )
- from litellm.vector_store_files.main import (
- create as vector_store_file_create_fn,
- )
- from litellm.vector_store_files.main import (
- delete as vector_store_file_delete_fn,
- )
- from litellm.vector_store_files.main import list as vector_store_file_list_fn
- from litellm.vector_store_files.main import (
- retrieve as vector_store_file_retrieve_fn,
- )
- from litellm.vector_store_files.main import (
- retrieve_content as vector_store_file_content_fn,
- )
- from litellm.vector_store_files.main import (
- update as vector_store_file_update_fn,
- )
-
- self.avector_store_file_create = self.factory_function(
- avector_store_file_create_fn, call_type="avector_store_file_create"
- )
- self.vector_store_file_create = self.factory_function(
- vector_store_file_create_fn, call_type="vector_store_file_create"
- )
- self.avector_store_file_list = self.factory_function(
- avector_store_file_list_fn, call_type="avector_store_file_list"
- )
- self.vector_store_file_list = self.factory_function(
- vector_store_file_list_fn, call_type="vector_store_file_list"
- )
- self.avector_store_file_retrieve = self.factory_function(
- avector_store_file_retrieve_fn, call_type="avector_store_file_retrieve"
- )
- self.vector_store_file_retrieve = self.factory_function(
- vector_store_file_retrieve_fn, call_type="vector_store_file_retrieve"
- )
- self.avector_store_file_content = self.factory_function(
- avector_store_file_content_fn, call_type="avector_store_file_content"
- )
- self.vector_store_file_content = self.factory_function(
- vector_store_file_content_fn, call_type="vector_store_file_content"
- )
- self.avector_store_file_update = self.factory_function(
- avector_store_file_update_fn, call_type="avector_store_file_update"
- )
- self.vector_store_file_update = self.factory_function(
- vector_store_file_update_fn, call_type="vector_store_file_update"
- )
- self.avector_store_file_delete = self.factory_function(
- avector_store_file_delete_fn, call_type="avector_store_file_delete"
- )
- self.vector_store_file_delete = self.factory_function(
- vector_store_file_delete_fn, call_type="vector_store_file_delete"
- )
-
- def _initialize_google_genai_endpoints(self):
- """Initialize Google GenAI endpoints."""
- from litellm.google_genai import (
- agenerate_content,
- agenerate_content_stream,
- generate_content,
- generate_content_stream,
- )
-
- self.agenerate_content = self.factory_function(agenerate_content, call_type="agenerate_content")
- self.generate_content = self.factory_function(generate_content, call_type="generate_content")
- self.agenerate_content_stream = self.factory_function(
- agenerate_content_stream, call_type="agenerate_content_stream"
- )
- self.generate_content_stream = self.factory_function(
- generate_content_stream, call_type="generate_content_stream"
- )
-
- def _initialize_ocr_search_endpoints(self):
- """Initialize OCR and search endpoints."""
- from litellm.ocr import aocr, ocr
-
- self.aocr = self.factory_function(aocr, call_type="aocr")
- self.ocr = self.factory_function(ocr, call_type="ocr")
-
- from litellm.search import asearch, search
-
- self.asearch = self.factory_function(asearch, call_type="asearch")
- self.search = self.factory_function(search, call_type="search")
-
- from litellm.decisions import adecisions, decisions
-
- self.adecisions = self.factory_function(adecisions, call_type="adecisions")
- self.decisions = self.factory_function(decisions, call_type="decisions")
-
- from litellm.systemone import asystemone, systemone
-
- self.asystemone = self.factory_function(asystemone, call_type="asystemone") # pyright: ignore[reportUnknownMemberType] # factory_function takes a bare Callable
- self.systemone = self.factory_function(systemone, call_type="systemone") # pyright: ignore[reportUnknownMemberType] # factory_function takes a bare Callable
-
- from litellm.extraction import aextraction, extraction
-
- self.aextraction = self.factory_function(aextraction, call_type="aextraction") # pyright: ignore[reportUnknownMemberType] # factory_function takes a bare Callable
- self.extraction = self.factory_function(extraction, call_type="extraction") # pyright: ignore[reportUnknownMemberType] # factory_function takes a bare Callable
-
- def _initialize_video_endpoints(self):
- """Initialize video endpoints."""
- from litellm.videos import (
- avideo_content,
- avideo_create_character,
- avideo_edit,
- avideo_extension,
- avideo_generation,
- avideo_get_character,
- avideo_list,
- avideo_remix,
- avideo_status,
- video_content,
- video_create_character,
- video_edit,
- video_extension,
- video_generation,
- video_get_character,
- video_list,
- video_remix,
- video_status,
- )
-
- self.avideo_generation = self.factory_function(avideo_generation, call_type="avideo_generation")
- self.video_generation = self.factory_function(video_generation, call_type="video_generation")
- self.avideo_list = self.factory_function(avideo_list, call_type="avideo_list")
- self.video_list = self.factory_function(video_list, call_type="video_list")
- self.avideo_status = self.factory_function(avideo_status, call_type="avideo_status")
- self.video_status = self.factory_function(video_status, call_type="video_status")
- self.avideo_content = self.factory_function(avideo_content, call_type="avideo_content")
- self.video_content = self.factory_function(video_content, call_type="video_content")
- self.avideo_remix = self.factory_function(avideo_remix, call_type="avideo_remix")
- self.video_remix = self.factory_function(video_remix, call_type="video_remix")
- self.avideo_create_character = self.factory_function(
- avideo_create_character, call_type="avideo_create_character"
- )
- self.video_create_character = self.factory_function(video_create_character, call_type="video_create_character")
- self.avideo_get_character = self.factory_function(avideo_get_character, call_type="avideo_get_character")
- self.video_get_character = self.factory_function(video_get_character, call_type="video_get_character")
- self.avideo_edit = self.factory_function(avideo_edit, call_type="avideo_edit")
- self.video_edit = self.factory_function(video_edit, call_type="video_edit")
- self.avideo_extension = self.factory_function(avideo_extension, call_type="avideo_extension")
- self.video_extension = self.factory_function(video_extension, call_type="video_extension")
-
- def _initialize_container_endpoints(self):
- """Initialize container endpoints."""
- from litellm.containers import (
- acreate_container,
- adelete_container,
- alist_containers,
- aretrieve_container,
- create_container,
- delete_container,
- list_containers,
- retrieve_container,
- )
- from litellm.containers.endpoint_factory import (
- _generated_endpoints as container_file_endpoints,
- )
-
- self.acreate_container = self.factory_function(acreate_container, call_type="acreate_container")
- self.create_container = self.factory_function(create_container, call_type="create_container")
- self.alist_containers = self.factory_function(alist_containers, call_type="alist_containers")
- self.list_containers = self.factory_function(list_containers, call_type="list_containers")
- self.aretrieve_container = self.factory_function(aretrieve_container, call_type="aretrieve_container")
- self.retrieve_container = self.factory_function(retrieve_container, call_type="retrieve_container")
- self.adelete_container = self.factory_function(adelete_container, call_type="adelete_container")
- self.delete_container = self.factory_function(delete_container, call_type="delete_container")
-
- # Auto-register JSON-generated container file endpoints
- for name, func in container_file_endpoints.items():
- setattr(self, name, self.factory_function(func, call_type=name))
-
- def _initialize_skills_endpoints(self):
- """Initialize Anthropic Skills API endpoints."""
- self.acreate_skill = self.factory_function(litellm.acreate_skill, call_type="acreate_skill")
- self.alist_skills = self.factory_function(litellm.alist_skills, call_type="alist_skills")
- self.aget_skill = self.factory_function(litellm.aget_skill, call_type="aget_skill")
- self.adelete_skill = self.factory_function(litellm.adelete_skill, call_type="adelete_skill")
-
- def _initialize_interactions_endpoints(self):
- """Initialize Google Interactions API endpoints."""
- from litellm.interactions import acancel as acancel_interaction
- from litellm.interactions import acreate as acreate_interaction
- from litellm.interactions import adelete as adelete_interaction
- from litellm.interactions import aget as aget_interaction
- from litellm.interactions import cancel as cancel_interaction
- from litellm.interactions import create as create_interaction
- from litellm.interactions import delete as delete_interaction
- from litellm.interactions import get as get_interaction
-
- self.acreate_interaction = self.factory_function(acreate_interaction, call_type="acreate_interaction")
- self.create_interaction = self.factory_function(create_interaction, call_type="create_interaction")
- self.aget_interaction = self.factory_function(aget_interaction, call_type="aget_interaction")
- self.get_interaction = self.factory_function(get_interaction, call_type="get_interaction")
- self.adelete_interaction = self.factory_function(adelete_interaction, call_type="adelete_interaction")
- self.delete_interaction = self.factory_function(delete_interaction, call_type="delete_interaction")
- self.acancel_interaction = self.factory_function(acancel_interaction, call_type="acancel_interaction")
- self.cancel_interaction = self.factory_function(cancel_interaction, call_type="cancel_interaction")
-
- def _initialize_managed_agents_endpoints(self):
- """Initialize Google Managed Agents API endpoints (v1beta/agents)."""
- from litellm.interactions.agents import acreate as acreate_agent
- from litellm.interactions.agents import adelete as adelete_agent
- from litellm.interactions.agents import aget as aget_agent
- from litellm.interactions.agents import alist as alist_agents
- from litellm.interactions.agents import alist_versions as alist_agent_versions
- from litellm.interactions.agents import create as create_agent
- from litellm.interactions.agents import delete as delete_agent
- from litellm.interactions.agents import get as get_agent
- from litellm.interactions.agents import list as list_agents
- from litellm.interactions.agents import list_versions as list_agent_versions
-
- self.acreate_agent = self.factory_function(acreate_agent, call_type="acreate_agent")
- self.create_agent = self.factory_function(create_agent, call_type="create_agent")
- self.alist_agents = self.factory_function(alist_agents, call_type="alist_agents")
- self.list_agents = self.factory_function(list_agents, call_type="list_agents")
- self.aget_agent = self.factory_function(aget_agent, call_type="aget_agent")
- self.get_agent = self.factory_function(get_agent, call_type="get_agent")
- self.adelete_agent = self.factory_function(adelete_agent, call_type="adelete_agent")
- self.delete_agent = self.factory_function(delete_agent, call_type="delete_agent")
- self.alist_agent_versions = self.factory_function(alist_agent_versions, call_type="alist_agent_versions")
- self.list_agent_versions = self.factory_function(list_agent_versions, call_type="list_agent_versions")
-
- def _initialize_specialized_endpoints(self):
- """Helper to initialize specialized router endpoints (vector store, OCR, search, video, container, skills, interactions)."""
- self._initialize_vector_store_endpoints()
- self._initialize_vector_store_file_endpoints()
- self._initialize_google_genai_endpoints()
- self._initialize_ocr_search_endpoints()
- # Override vector store methods with router-aware implementations
- self._override_vector_store_methods_for_router()
- self._initialize_video_endpoints()
- self._initialize_container_endpoints()
- self._initialize_skills_endpoints()
- self._initialize_interactions_endpoints()
- self._initialize_managed_agents_endpoints()
-
- def initialize_router_endpoints(self):
- self._initialize_core_endpoints()
- self._initialize_specialized_endpoints()
-
- def validate_fallbacks(self, fallback_param: Sequence[Mapping[str, object]] | None):
- """
- Validate the fallbacks parameter.
- """
- if fallback_param is None:
- return
- for fallback_dict in fallback_param:
- if not isinstance(fallback_dict, dict):
- raise ValueError(f"Item '{fallback_dict}' is not a dictionary.")
- if len(fallback_dict) != 1:
- raise ValueError(
- f"Dictionary '{fallback_dict}' must have exactly one key, but has {len(fallback_dict)} keys."
- )
- ((model_group, targets),) = fallback_dict.items()
- if isinstance(targets, list) or check_non_standard_fallback_format(fallbacks=[fallback_dict]):
- continue
- raise TypeError(
- f"Fallbacks for '{model_group}' must be a list of model names, got '{targets}'. "
- f"Use {{'{model_group}': ['{targets}']}}"
- )
-
- def _add_encrypted_content_affinity_check(self, enable_global_affinity: bool) -> None:
- def _move_before_deployment_affinity(
- callback_list: list[_CallbackT],
- callback_to_move: _CallbackT,
- ) -> None:
- if callback_to_move not in callback_list:
+ def __setattr__(cls, name: str, value: object) -> None:
+ core_class: Final = vars(cls).get("_core_class") or getattr(cls, "_core_class", None)
+ if core_class is None or any(name in vars(base) for base in cls.__mro__) or not hasattr(core_class, name):
+ super().__setattr__(name, value)
return
- callback_list.remove(callback_to_move)
- insert_index: Final = next(
- (idx for idx, callback in enumerate(callback_list) if isinstance(callback, DeploymentAffinityCheck)),
- len(callback_list),
- )
- callback_list.insert(insert_index, callback_to_move)
+ _replaced_core_members.setdefault((core_class, name), []).append(vars(core_class).get(name, _MISSING))
+ setattr(core_class, name, value)
- if enable_global_affinity or EncryptedContentAffinityCheck.has_model_group_affinity_enabled(
- self.model_group_affinity_config
- ):
- if self.optional_callbacks is None:
- self.optional_callbacks = []
-
- existing_ec_callback: EncryptedContentAffinityCheck | None = None
- for cb in self.optional_callbacks:
- if isinstance(cb, EncryptedContentAffinityCheck):
- existing_ec_callback = cb
- break
-
- if existing_ec_callback is not None:
- existing_ec_callback.ctx = self._core_ctx
- existing_ec_callback.enable_global_affinity = (
- existing_ec_callback.enable_global_affinity or enable_global_affinity
- )
- existing_ec_callback.model_group_affinity_config = self.model_group_affinity_config or {}
- ec_callback = existing_ec_callback
- else:
- ec_callback = EncryptedContentAffinityCheck(
- ctx=self._core_ctx,
- enable_global_affinity=enable_global_affinity,
- model_group_affinity_config=self.model_group_affinity_config,
- )
- self.optional_callbacks.append(ec_callback)
- litellm.logging_callback_manager.add_litellm_callback(ec_callback)
-
- _move_before_deployment_affinity(self.optional_callbacks, ec_callback)
- _move_before_deployment_affinity(litellm.callbacks, ec_callback)
-
- def _ensure_deployment_affinity_callback(self) -> None:
- """Register the DeploymentAffinityCheck callback (global flags all False) if absent.
-
- Needed when nothing enabled a global affinity flag but affinity can still
- activate per request: per-group `model_group_affinity_config` entries, or the
- session-affinity marker a complexity router stamps at pre-routing time.
- """
- if any(isinstance(cb, DeploymentAffinityCheck) for cb in (self.optional_callbacks or [])):
- return
- if self.optional_callbacks is None:
- self.optional_callbacks = []
- affinity_callback: Final = DeploymentAffinityCheck(
- cache=self._core_cache,
- ttl_seconds=self.deployment_affinity_ttl_seconds,
- enable_user_key_affinity=False,
- enable_responses_api_affinity=False,
- enable_session_id_affinity=False,
- model_group_affinity_config=self.model_group_affinity_config,
- is_priority_group=self._is_priority_routing_group,
- session_affinity_repin_on_failover=self.session_affinity_repin_on_failover,
- )
- self.optional_callbacks.append(affinity_callback)
- litellm.logging_callback_manager.add_litellm_callback(affinity_callback)
-
- def add_optional_pre_call_checks(self, optional_pre_call_checks: OptionalPreCallChecks | None):
- if optional_pre_call_checks is None:
- return
-
- # ---------------------------------------------------------------------
- # Unified deployment affinity (session stickiness)
- # ---------------------------------------------------------------------
- enable_user_key_affinity: Final = "deployment_affinity" in optional_pre_call_checks
- enable_responses_api_affinity: Final = "responses_api_deployment_check" in optional_pre_call_checks
- enable_session_id_affinity: Final = "session_affinity" in optional_pre_call_checks
- if enable_user_key_affinity or enable_responses_api_affinity or enable_session_id_affinity:
- if self.optional_callbacks is None:
- self.optional_callbacks = []
-
- existing_affinity_callback: DeploymentAffinityCheck | None = None
- for cb in self.optional_callbacks:
- if isinstance(cb, DeploymentAffinityCheck):
- existing_affinity_callback = cb
- break
-
- if existing_affinity_callback is not None:
- existing_affinity_callback.enable_user_key_affinity = (
- existing_affinity_callback.enable_user_key_affinity or enable_user_key_affinity
- )
- existing_affinity_callback.enable_responses_api_affinity = (
- existing_affinity_callback.enable_responses_api_affinity or enable_responses_api_affinity
- )
- existing_affinity_callback.enable_session_id_affinity = (
- existing_affinity_callback.enable_session_id_affinity or enable_session_id_affinity
- )
- existing_affinity_callback.ttl_seconds = self.deployment_affinity_ttl_seconds
- if self.model_group_affinity_config:
- existing_affinity_callback.model_group_affinity_config = self.model_group_affinity_config
- else:
- affinity_callback: Final = DeploymentAffinityCheck(
- cache=self._core_cache,
- ttl_seconds=self.deployment_affinity_ttl_seconds,
- enable_user_key_affinity=enable_user_key_affinity,
- enable_responses_api_affinity=enable_responses_api_affinity,
- enable_session_id_affinity=enable_session_id_affinity,
- model_group_affinity_config=self.model_group_affinity_config,
- is_priority_group=self._is_priority_routing_group,
- session_affinity_repin_on_failover=self.session_affinity_repin_on_failover,
- )
- self.optional_callbacks.append(affinity_callback)
- litellm.logging_callback_manager.add_litellm_callback(affinity_callback)
-
- # ---------------------------------------------------------------------
- # Encrypted content affinity
- # ---------------------------------------------------------------------
- self._add_encrypted_content_affinity_check(
- enable_global_affinity=("encrypted_content_affinity" in optional_pre_call_checks)
- )
-
- # ---------------------------------------------------------------------
- # Remaining optional pre-call checks
- # ---------------------------------------------------------------------
- for pre_call_check in optional_pre_call_checks:
- _callback: CustomLogger | None = None
- if pre_call_check in (
- "deployment_affinity",
- "responses_api_deployment_check",
- "session_affinity",
- "encrypted_content_affinity",
- ):
- continue
- if pre_call_check == "prompt_caching":
- _callback = PromptCachingDeploymentCheck(
- cache=self._core_prompt_caching_pin_cache, is_priority_group=self._is_priority_routing_group
- )
- elif pre_call_check == "router_budget_limiting":
- if self._core_get_router_deployment_budget_limiter() is not None:
- continue
- _callback = RouterBudgetLimiting(
- dual_cache=self._core_cache,
- provider_budget_config=self._core_provider_budget_config,
- model_list=self._core_model_list,
- )
- self.router_budget_logger = _callback
- elif pre_call_check == "enforce_model_rate_limits":
- _callback = ModelRateLimitingCheck(dual_cache=self._core_cache)
-
- if _callback is None:
- continue
-
- if self.optional_callbacks is not None and any(
- isinstance(callback, type(_callback)) for callback in self.optional_callbacks
- ):
- continue
- if self.optional_callbacks is None:
- self.optional_callbacks = []
- self.optional_callbacks.append(_callback)
- litellm.logging_callback_manager.add_litellm_callback(_callback)
-
- def set_optional_pre_call_checks(self, optional_pre_call_checks: OptionalPreCallChecks | None) -> None:
- if optional_pre_call_checks is None:
- return
- requested: Final = frozenset(optional_pre_call_checks)
- for name, callback_cls in _RUNTIME_TOGGLEABLE_PRE_CALL_CHECKS.items():
- if name not in requested:
- self._remove_optional_callbacks_of_type(callback_cls)
- self.add_optional_pre_call_checks(optional_pre_call_checks)
-
- def _remove_optional_callbacks_of_type(self, callback_cls: type[CustomLogger]) -> None:
- if self.optional_callbacks is None or not any(type(cb) is callback_cls for cb in self.optional_callbacks):
- return
- self.optional_callbacks = [cb for cb in self.optional_callbacks if type(cb) is not callback_cls]
- if any(
- router is not self and any(type(cb) is callback_cls for cb in (router.optional_callbacks or []))
- for router in tuple(_live_routers)
- ):
- return
- for cb in tuple(litellm.callbacks):
- if type(cb) is callback_cls:
- litellm.logging_callback_manager.remove_callback_from_list_by_object(
- litellm.callbacks, cb, require_self=False
- )
-
- def print_deployment(self, deployment: dict):
- """
- returns a copy of the deployment with the api key masked
-
- Only returns 2 characters of the api key and masks the rest with * (10 *).
- """
- try:
- _deployment_copy: Final = copy.deepcopy(deployment)
- litellm_params: Final[dict] = _deployment_copy["litellm_params"]
-
- if litellm.redact_user_api_key_info:
- masker: Final = SensitiveDataMasker(visible_prefix=2, visible_suffix=0)
- _deployment_copy["litellm_params"] = masker.mask_dict(litellm_params)
- elif "api_key" in litellm_params:
- litellm_params["api_key"] = litellm_params["api_key"][:2] + "*" * 10
-
- return _deployment_copy
- except Exception as e:
- verbose_router_logger.debug("Error occurred while printing deployment - %s", e)
- raise e
-
- @staticmethod
- def _deployment_params_with_request_reasoning_override(
- deployment_params: Mapping[str, object], request_kwargs: Mapping[str, object]
- ) -> dict[str, object]: # mutable-ok: litellm's request pipeline consumes a mutable kwargs mapping
- """Return deployment params whose equivalent effort controls cannot outrank a request override.
-
- Providers expose the same setting through several native carriers. A request-level
- ``reasoning_effort`` is the portable override, so a deployment's ``thinking`` or nested
- ``*.effort`` must not remain beside it and either win or trigger a conflicting-params 400.
- Every changed mapping is copied so the Router's shared deployment config stays immutable.
- """
- sanitized: Final = dict(deployment_params)
- if request_kwargs.get("reasoning_effort") is None:
- return sanitized
-
- sanitized.pop("thinking", None)
- Router._pop_effort_from_nested_carrier(sanitized, "output_config")
- Router._pop_effort_from_nested_carrier(sanitized, "reasoning")
-
- extra_body: Final = sanitized.get("extra_body")
- if isinstance(extra_body, Mapping):
- sanitized_extra_body: Final = dict(extra_body)
- sanitized_extra_body.pop("reasoning_effort", None)
- sanitized_extra_body.pop("thinking", None)
- Router._pop_effort_from_nested_carrier(sanitized_extra_body, "output_config")
- Router._pop_effort_from_nested_carrier(sanitized_extra_body, "reasoning")
- if sanitized_extra_body:
- sanitized["extra_body"] = sanitized_extra_body
- else:
- sanitized.pop("extra_body", None)
- return sanitized
-
- @staticmethod
- def _is_classifier_internal_call(kwargs: Mapping[str, object]) -> bool:
- metadata: Final = kwargs.get("metadata")
- litellm_metadata: Final = kwargs.get("litellm_metadata")
- return any(
- isinstance(candidate, Mapping)
- and candidate.get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == AUTOROUTER_CLASSIFIER_CALL_ORIGIN
- for candidate in (metadata, litellm_metadata)
- )
-
- def _drop_unsupported_classifier_reasoning_effort(
- self,
- deployment: DeploymentTypedDict,
- model: str,
- kwargs: dict[str, object], # mutable-ok: fallback must update the active request and its log body together
- ) -> None:
- """Let a classifier fallback without reasoning support remain a usable fallback.
-
- The dashboard only offers explicitly advertised levels, but an existing config can outlive
- a model change and fallbacks can target a different group. Unknown capability fails open;
- only a provider that explicitly rejects the parameter has it removed.
- """
- if kwargs.get("reasoning_effort") is None or not self._is_classifier_internal_call(kwargs):
- return
- if self._deployment_accepts_param(deployment, model, "reasoning_effort"):
- return
- verbose_router_logger.warning(
- "litellm.router.py: dropping classifier reasoning_effort for model=%s because the selected deployment does not support it",
- model,
- )
- kwargs.pop("reasoning_effort", None)
- proxy_server_request: Final = kwargs.get("proxy_server_request")
- if not isinstance(proxy_server_request, dict):
- return
- body: Final = proxy_server_request.get("body")
- if isinstance(body, dict):
- body.pop("reasoning_effort", None)
-
- ### COMPLETION, EMBEDDING, IMG GENERATION FUNCTIONS
-
- def completion(self, model: str, messages: list[dict[str, str]], **kwargs) -> ModelResponse | CustomStreamWrapper:
- """
- Example usage:
- response = router.completion(model="gpt-3.5-turbo", messages=[{"role": "user", "content": "Hey, how's it going?"}]
- """
- try:
- verbose_router_logger.debug("router.completion(model=%s,..)", model)
- kwargs["model"] = model
- kwargs["messages"] = messages
- kwargs["original_function"] = self._completion
- controls: Final = per_request_fallback_controls(kwargs)
- kwargs[MID_STREAM_FALLBACK_CONTROLS_KEY] = controls # rebind-ok: forwarded to every hop
- self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
-
- response: Final = self.function_with_fallbacks(**kwargs)
- return response
- except Exception as e:
- raise e
-
- def _completion(self, model: str, messages: list[dict[str, str]], **kwargs) -> ModelResponse | CustomStreamWrapper:
- model_name = None
- deployment = None
- try:
- controls: Final = kwargs.pop(MID_STREAM_FALLBACK_CONTROLS_KEY, None)
- input_kwargs_for_streaming_fallback: Final = mid_stream_fallback_snapshot_kwargs(
- model=model, controls=controls, kwargs=kwargs
- )
-
- # pick the one that is available (lowest TPM/RPM)
- deployment = self.get_available_deployment(
- model=model,
- messages=messages,
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
- self._drop_unsupported_classifier_reasoning_effort(
- deployment=cast(DeploymentTypedDict, deployment), # cast-ok: selection returns a router deployment
- model=model,
- kwargs=kwargs,
- )
- # Check for silent model experiment
- # Make a local copy of litellm_params to avoid mutating the Router's state
- litellm_params: Final = self._deployment_params_with_request_reasoning_override(
- deployment["litellm_params"], kwargs
- )
- silent_model: Final = litellm_params.pop("silent_model", None)
-
- for silent_target in _silent_experiment_targets(silent_model):
- # Mirroring traffic to a secondary model
- # Use threading.Thread (not ThreadPoolExecutor) - executor.submit()
- # requires pickling args, which fails when kwargs contain unpicklable
- # objects (e.g. _thread.RLock from OTEL spans, loggers) in deployment.
- threading.Thread(
- target=self._silent_experiment_completion,
- args=(silent_target, messages),
- kwargs=_silent_experiment_kwargs_snapshot(kwargs),
- daemon=True,
- ).start()
-
- kwargs.setdefault("messages", messages)
- self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, splits_stream_timeout=True)
- kwargs.pop("silent_model", None) # Ensure it's not in kwargs either
- model_name = litellm_params["model"]
- potential_model_client: Final = self._get_client(deployment=deployment, kwargs=kwargs)
- # check if provided keys == client keys #
- dynamic_api_key: Final = kwargs.get("api_key", None)
- if (
- dynamic_api_key is not None
- and potential_model_client is not None
- and dynamic_api_key != potential_model_client.api_key
- ):
- model_client = None
- else:
- model_client = potential_model_client
-
- ### DEPLOYMENT-SPECIFIC PRE-CALL CHECKS ### (e.g. update rpm pre-call. Raise error, if deployment over limit)
- ## only run if model group given, not model id
- if model in self._core_model_names or not self._core_has_model_id(model):
- self.routing_strategy_pre_call_checks(deployment=deployment)
-
- input_kwargs: Final = without_router_only_kwargs(
- {
- **litellm_params,
- "messages": messages,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs,
- }
- )
- response: Final = litellm.completion(**input_kwargs)
- verbose_router_logger.info("litellm.completion(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
-
- ## CHECK CONTENT FILTER ERROR ##
- if isinstance(response, ModelResponse):
- _should_raise = self._should_raise_content_policy_error(model=model, response=response, kwargs=kwargs)
- if _should_raise:
- raise litellm.ContentPolicyViolationError(
- message="Response output was blocked.",
- model=model,
- llm_provider="",
- )
-
- if (
- isinstance(response, CustomStreamWrapper)
- and response.completion_stream is None
- and response.make_call is not None
- ):
- response.fetch_sync_stream()
-
- # Wrap streaming responses so MidStreamFallbackError (raised
- # during iteration) triggers the Router's fallback chain.
- if isinstance(response, CustomStreamWrapper):
- return self._completion_streaming_iterator(
- model_response=response,
- messages=messages,
- initial_kwargs=input_kwargs_for_streaming_fallback,
- first_chunk=_read_first_chunk(response),
- )
-
- return response
- except Exception as e:
- verbose_router_logger.info("litellm.completion(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
- # Set per-deployment num_retries on exception for retry logic
- if deployment is not None:
- self._set_deployment_num_retries_on_exception(e, deployment)
- self._stamp_failed_deployment_id_with_effective_model_info(e, deployment, kwargs)
- raise e
-
- def _get_silent_experiment_kwargs(self, **kwargs) -> dict:
- """
- Prepare kwargs for a silent experiment by ensuring isolation from the primary call.
-
- Guarantee metadata isolation: safe_deep_copy falls back to the original
- reference when deepcopy fails (e.g. metadata contains UserAPIKeyAuth with
- parent_otel_span — an OTel Span that is not deepcopy-able). Force a shallow
- copy of the metadata dict so mutations (model_group, is_silent_experiment)
- never corrupt the main call's metadata.
- """
- from litellm.litellm_core_utils.core_helpers import safe_deep_copy
-
- silent_kwargs: Final = safe_deep_copy(kwargs)
-
- # safe_deep_copy may fall back to the original metadata reference when
- # deepcopy fails (UserAPIKeyAuth.parent_otel_span is not deepcopy-able).
- # Detect this via identity check and force a shallow copy so that setting
- # model_group / is_silent_experiment on the silent dict doesn't corrupt
- # the primary call's metadata.
- original_metadata: Final = kwargs.get("metadata")
- if original_metadata is not None and silent_kwargs.get("metadata") is original_metadata:
- silent_kwargs["metadata"] = dict(original_metadata)
-
- if "metadata" not in silent_kwargs:
- silent_kwargs["metadata"] = {}
-
- # OTel spans are not safe to use across event loops. The silent
- # experiment runs in a new event loop, so strip the span to prevent
- # cross-loop tracing races or span corruption.
- silent_kwargs["metadata"].pop("litellm_parent_otel_span", None)
-
- silent_kwargs["metadata"]["is_silent_experiment"] = True
-
- # Pop logging objects and call IDs to ensure a fresh logging context
- # This prevents collisions in the Proxy's database (spend_logs)
- silent_kwargs.pop("litellm_call_id", None)
- silent_kwargs.pop("litellm_logging_obj", None)
- silent_kwargs.pop("standard_logging_object", None)
- # DON'T pop proxy_server_request — it's needed for spend log metadata
-
- return silent_kwargs
-
- async def _run_silent_experiment(
- self, silent_model: str, messages: Sequence[Mapping[str, str]], silent_kwargs: Mapping[str, object]
- ) -> None:
- remaining_kwargs: Final = MappingProxyType(
- {key: value for key, value in silent_kwargs.items() if key != "stream"}
- )
- with mirrored_call():
- response: Final = await self.acompletion(
- model=silent_model,
- messages=cast(list[AllMessageValues], messages),
- stream=bool(silent_kwargs.get("stream", False)),
- **remaining_kwargs,
- )
- if not isinstance(response, CustomStreamWrapper):
+ def __delattr__(cls, name: str) -> None:
+ core_class: Final = getattr(cls, "_core_class", None)
+ replaced: Final = _replaced_core_members.get((core_class, name)) if core_class is not None else None
+ if not replaced or any(name in vars(base) for base in cls.__mro__):
+ super().__delattr__(name)
return
- async for _ in response:
- pass
-
- def _silent_experiment_completion(self, silent_model: str, messages: Sequence[Mapping[str, str]], **kwargs):
- """
- Run a silent experiment in the background (thread).
- """
- try:
- # Prevent infinite recursion if silent model also has a silent model
- if kwargs.get("metadata", {}).get("is_silent_experiment", False):
+ original: Final = replaced.pop()
+ if original is _MISSING:
+ delattr(core_class, name)
return
+ setattr(core_class, name, original)
- messages = copy.deepcopy(messages)
+ def __instancecheck__(cls, instance: object) -> bool:
+ return type.__instancecheck__(cls, instance) or isinstance(instance, cls._core_class)
- verbose_router_logger.info("Starting silent experiment for model %s", silent_model)
+ def __dir__(cls) -> list[str]: # mutable-ok: dir() returns a list
+ return sorted({*type.__dir__(cls), *dir(cls._core_class)})
- silent_kwargs: Final = self._get_silent_experiment_kwargs(**kwargs)
+ class _CoreType(_RouterType):
+ """The metaclass of a Router subclass's core class: a plain class, none of the facade's redirects."""
- # Override model_group to correctly attribute metrics to the silent model
- silent_kwargs["metadata"]["model_group"] = silent_model
+ def __getattr__(cls, name: str) -> object:
+ raise AttributeError(f"type object {cls.__name__!r} has no attribute {name!r}")
- # Create a new event loop for this thread so that async success
- # callbacks (e.g. _ProxyDBLogger) can schedule and run DB writes.
- loop: Final = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
+ __setattr__ = type.__setattr__
+ __delattr__ = type.__delattr__
+ __instancecheck__ = type.__instancecheck__
+ __dir__ = type.__dir__
+
+ def _is_core(instance: object) -> bool:
+ return isinstance(type(instance), _CoreType)
+
+ def _patchable_method(core: object, name: str) -> bool:
+ """A method of the core: an instance-level replacement of it is also kept on the facade, so
+ ``mock.patch.object(router, name)`` finds it there and nested patches unwind in order. A core
+ that serves its members from another core (``__getattr__``) is asked for the bound method."""
+ static: Final = inspect.getattr_static(type(core), name, None)
+ if static is not None:
+ return isinstance(static, FunctionType | staticmethod | classmethod)
+ return hasattr(type(core), "__getattr__") and inspect.ismethod(getattr(core, name, None))
+
+ class Router(DeprecatedRouterMembers, metaclass=_RouterType):
+ _core_class: type[PythonCore] = PythonCore
+
+ def __init_subclass__(cls, **kwargs: object) -> None: # kwargs-ok: class keywords, passed on to super()
+ if isinstance(cls, _CoreType):
+ # A core class built below: the parent core's hooks wire it, as for any core class.
+ super().__init_subclass__(**kwargs)
+ return
+ # DeprecatedRouterMembers wires a subclass's overrides of old members; here that runs
+ # on the core class built from it, which is where the router's own calls land.
+ super(DeprecatedRouterMembers, cls).__init_subclass__(**kwargs)
+ type.__setattr__(cls, "_core_class", _core_class_for(cls, cls._core_class))
+
+ def __init__(self, *args: object, **kwargs: object) -> None: # kwargs-ok: Router's constructor arguments
+ self._attach(
+ make_core(build_router_config(router_arguments(*args, **kwargs)), python_host, type(self)._core_class)
+ )
+
+ @classmethod
+ def from_config(cls, config: RouterConfig, host: PythonHost = python_host) -> "Router":
+ router: Final = cls.__new__(cls)
+ router._attach(make_core(config, host, cls._core_class))
+ return router
+
+ def _attach(self, core: RouterCore) -> None:
+ pending: Final = self.__dict__.pop("_pending", {})
+ self.__dict__["_core"] = core
+ core.__dict__["_core_owner"] = weakref.ref(self) # rebind-ok: the facade records itself on the core it owns
+ for name, value in pending.items():
+ setattr(core, name, value)
+
+ @property
+ def _core(self) -> RouterCore:
+ core: Final = self.__dict__.get("_core")
+ if core is None:
+ raise AttributeError("_core")
+ return core
+
+ def __getattribute__(self, name: str) -> object:
try:
-
- async def _run_silent_completion():
- await self._run_silent_experiment(silent_model, messages, silent_kwargs)
- # Drain any fire-and-forget tasks (e.g. alerting hooks)
- # scheduled via asyncio.create_task during acompletion.
- pending: Final = asyncio.all_tasks()
- current: Final = asyncio.current_task()
- if current is not None:
- pending.discard(current)
- if pending:
- await asyncio.gather(*pending, return_exceptions=True)
-
- loop.run_until_complete(_run_silent_completion())
- finally:
- loop.close()
- except Exception as e:
- verbose_router_logger.error("Silent experiment failed for model %s: %s", silent_model, e)
-
- # fmt: off
-
- @overload
- async def acompletion(
- self, model: str, messages: list[AllMessageValues], stream: Literal[True], **kwargs
- ) -> CustomStreamWrapper:
- ...
-
- @overload
- async def acompletion(
- self, model: str, messages: list[AllMessageValues], stream: Literal[False] = False, **kwargs
- ) -> ModelResponse:
- ...
-
- @overload
- async def acompletion(
- self, model: str, messages: list[AllMessageValues], stream: Literal[True, False] = False, **kwargs
- ) -> CustomStreamWrapper | ModelResponse:
- ...
-
- # fmt: on
-
- # The actual implementation of the function
- async def acompletion(
- self,
- model: str,
- messages: list[AllMessageValues],
- stream: bool = False,
- **kwargs,
- ):
- try:
- kwargs["model"] = model
- kwargs["messages"] = messages
- kwargs["stream"] = stream
- kwargs["original_function"] = self._acompletion
-
- self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
- start_time: Final = time.time()
- is_prompt_management_model: Final = self._is_prompt_management_model(model)
- if not is_prompt_management_model:
- controls: Final = per_request_fallback_controls(kwargs)
- kwargs[MID_STREAM_FALLBACK_CONTROLS_KEY] = controls # rebind-ok: forwarded to every hop
- request_priority, request_kwargs = self._core_split_request_priority(model=model, kwargs=kwargs)
- original_function: Final[Callable[..., Awaitable[object]]] = (
- self._prompt_management_acompletion(model=model, messages=messages)
- if is_prompt_management_model
- else self.async_function_with_fallbacks
- )
- if request_priority is None:
- response = await original_function(**request_kwargs)
- else:
- response = await self._schedule_factory(
- model=model,
- priority=request_priority,
- original_function=original_function,
- args=(),
- kwargs=request_kwargs,
- )
- end_time: Final = time.time()
- _duration: Final = end_time - start_time
- asyncio.create_task(
- self.service_logger_obj.async_service_success_hook(
- service=ServiceTypes.ROUTER,
- duration=_duration,
- call_type="acompletion",
- start_time=start_time,
- end_time=end_time,
- parent_otel_span=get_parent_otel_span_from_kwargs(kwargs),
- )
- )
-
- return response
- except Exception as e:
- asyncio.create_task(
- send_llm_exception_alert(
- ctx=self._core_ctx,
- request_kwargs=kwargs,
- error_traceback_str=traceback.format_exc(),
- original_exception=e,
- )
- )
- raise e
-
- @staticmethod
- def _combine_fallback_usage(
- fallback_item: ModelResponseStream,
- complete_response_object_usage: Usage | None,
- ) -> None:
- """Merge partial-stream usage with fallback-stream usage on the chunk."""
- from litellm.cost_calculator import BaseTokenUsageProcessor
-
- usage: Final = cast(Usage | None, getattr(fallback_item, "usage", None))
- usage_objects: Final = [usage] if usage is not None else []
- if complete_response_object_usage is not None:
- usage_objects.append(complete_response_object_usage)
- combined_usage: Final = BaseTokenUsageProcessor.combine_usage_objects(usage_objects=usage_objects)
- setattr(fallback_item, "usage", combined_usage)
-
- @staticmethod
- def _price_cut_partial(
- cut_stream: CustomStreamWrapper,
- partial_response: ModelResponse | TextCompletionResponse | None,
- ) -> "ContinuedPartial | None":
- """Price the cut stream's partial, plus any partial it inherited, before a fallback can reuse its logging."""
- from litellm.cost_calculator import BaseTokenUsageProcessor
-
- cut_logging: Final = cut_stream.logging_obj
- partial_usage: Final = getattr(partial_response, "usage", None)
- if partial_response is None or not isinstance(partial_usage, Usage):
- return None
- inherited_usage: Final = cut_logging.model_call_details.get("continued_partial_usage")
- inherited_cost: Final = cut_logging.model_call_details.get("continued_partial_cost")
- inherited_id: Final = cut_logging.model_call_details.get("continued_response_id")
- priced_partial: Final = partial_response.model_copy(
- update={"model": cut_stream.model} if cut_stream.model else {}
- )
- priced_partial._hidden_params = { # pyright: ignore[reportPrivateUsage] # no public accessor
- key: value for key, value in get_hidden_params_dict(partial_response).items() if key != "response_cost"
- }
- if LiteLLMLogging.usage_capture_callback is not None:
- from litellm._internal_context import internal_sub_call
-
- with internal_sub_call(cut_logging.litellm_call_id):
- cut_logging.capture_usage_before_logging(priced_partial)
- return ContinuedPartial(
- usage=BaseTokenUsageProcessor.combine_usage_objects(
- usage_objects=[partial_usage, inherited_usage]
- if isinstance(inherited_usage, Usage)
- else [partial_usage]
- ),
- response_cost=(cut_logging.response_cost_calculator(result=priced_partial) or 0.0)
- + (inherited_cost if isinstance(inherited_cost, float) else 0.0),
- response_id=inherited_id if isinstance(inherited_id, str) else partial_response.id,
- )
-
- @staticmethod
- def _carry_partial_spend_to_fallback(fallback_response: object, cut_partial: "ContinuedPartial | None") -> None:
- """Bill what the cut legs consumed on the continuation's success log."""
- fallback_logging: Final = getattr(fallback_response, "logging_obj", None)
- if cut_partial is None or not isinstance(fallback_logging, LiteLLMLogging):
- return
- fallback_logging.record_continued_partial(
- usage=cut_partial.usage, response_cost=cut_partial.response_cost, response_id=cut_partial.response_id
- )
-
- @staticmethod
- def _prepare_fallback_hidden_params(
- fallback_response: object,
- ) -> tuple[dict[str, object], dict[str, object]]:
- fallback_hidden_params: Final = get_hidden_params_dict(fallback_response)
- fallback_headers: Final = fallback_hidden_params.get("additional_headers")
- if not isinstance(fallback_headers, dict):
- return fallback_hidden_params, {}
- return fallback_hidden_params, cast("dict[str, object]", fallback_headers)
-
- @staticmethod
- def _adopt_fallback_response_headers(
- wrapper_ref: "weakref.ref[FallbackAwareStreamWrapper]",
- fallback_response: object,
- ) -> tuple[dict[str, object], dict[str, object]]:
- """Repoint the wrapper at `fallback_response`, returning its prepared hidden params."""
- prepared: Final = Router._prepare_fallback_hidden_params(fallback_response)
- adopting_wrapper: Final = wrapper_ref()
- if adopting_wrapper is not None:
- adopting_wrapper.adopt_fallback_response_headers(fallback_response, prepared)
- return prepared
-
- @staticmethod
- def _apply_fallback_hidden_params_to_item(
- fallback_item: object,
- prepared_fallback_hidden_params: tuple[dict[str, object], dict[str, object]],
- ) -> None:
- if fallback_item is None or not hasattr(fallback_item, _HIDDEN_PARAMS_ATTR):
- return
-
- fallback_hidden_params, fallback_headers = prepared_fallback_hidden_params
- item_hidden_params: Final = get_hidden_params_dict(fallback_item)
- item_headers = item_hidden_params.get("additional_headers")
- if not isinstance(item_headers, dict):
- item_headers = {}
-
- set_hidden_params(
- fallback_item,
- {
- **item_hidden_params,
- **fallback_hidden_params,
- "additional_headers": {**item_headers, **fallback_headers},
- },
- )
-
- async def _acompletion_streaming_iterator(
- self,
- model_response: CustomStreamWrapper,
- messages: list[dict[str, str]],
- initial_kwargs: dict[str, Any],
- deployment_slot: contextlib.AsyncExitStack | None = None,
- first_chunk: _FirstChunk[AsyncIterator[object]] | None = None,
- ) -> CustomStreamWrapper:
- """
- Helper to iterate over a streaming response.
-
- Catches errors for fallbacks using the router's fallback system
-
- `deployment_slot` holds the deployment's max_parallel_requests semaphore; it is
- released when the stream is exhausted, closed, or falls back to another deployment
-
- `first_chunk` carries the chunk _acompletion already read (None: iterate from the start)
- """
- from litellm.exceptions import MidStreamFallbackError
-
- held_slot: Final = deployment_slot if deployment_slot is not None else contextlib.AsyncExitStack()
-
- class FallbackStreamWrapper(FallbackAwareStreamWrapper):
- def __init__(self, async_generator: AsyncGenerator):
- # Copy attributes from the original model_response
- super().__init__(
- completion_stream=async_generator,
- model=model_response.model,
- custom_llm_provider=model_response.custom_llm_provider,
- logging_obj=model_response.logging_obj,
- _response_headers=getattr(model_response, "_response_headers", None),
- )
- self._async_generator = async_generator
- inner_chunks: Final[object] = getattr(model_response, "chunks", None)
- if isinstance(inner_chunks, list):
- self.chunks = inner_chunks
- # Preserve hidden params (including litellm_overhead_time_ms) from original response
- model_response_hidden_params: Final = get_hidden_params(model_response)
- if model_response_hidden_params is not None:
- self._hidden_params = dict(model_response_hidden_params)
-
- def __aiter__(self):
- return self
-
- async def __anext__(self):
- return await self._async_generator.__anext__()
-
- async def close_model_response() -> None:
- if not hasattr(model_response, "aclose"):
- return
- try:
- await model_response.aclose()
- except BaseException as e:
- verbose_router_logger.debug(
- "stream_with_fallbacks: error closing model_response: %s",
- e,
- )
-
- async def stream_with_fallbacks():
- fallback_response = None # Track for cleanup in finally
- try:
- async for item in _achunks_with_first(model_response, first_chunk):
- yield item
- except (MidStreamFallbackError, litellm.ContextWindowExceededError) as stream_error:
- e: Final = (
- _context_window_stream_fallback_error(
- stream_error,
- getattr(model_response, "model", ""),
- model_response.custom_llm_provider or "",
- bool(getattr(model_response, "response_uptil_now", ""))
- or _stream_chunks_have_generated_content(model_response.chunks),
- )
- if isinstance(stream_error, litellm.ContextWindowExceededError)
- or isinstance(stream_error.original_exception, litellm.ContextWindowExceededError)
- else stream_error
- )
- with anyio.CancelScope(shield=True):
- await close_model_response()
- await held_slot.aclose()
- committed: Final = bool(
- not e.is_pre_first_chunk
- and (e.generated_content or _stream_chunks_have_generated_content(model_response.chunks))
- )
- cut_hidden_params: Final = get_hidden_params(model_response) or {}
- continue_after_content: Final = committed and self._mid_stream_continuation_eligible(
- e=e, request_kwargs=initial_kwargs, cut_deployment_id=cut_hidden_params.get("model_id")
- )
- if committed and not continue_after_content:
- self._raise_original_mid_stream_error(e)
-
- from litellm.main import stream_chunk_builder
-
- complete_response_object: Final = stream_chunk_builder(
- chunks=model_response.chunks, messages=messages if continue_after_content else None
- )
- complete_response_object_usage: Final = cast(
- Usage | None,
- getattr(complete_response_object, "usage", None),
- )
- try:
- # Use the router's fallback system
- model_group: Final = cast(str, initial_kwargs.get("model"))
- fallbacks: Final[list | None] = initial_kwargs.get("fallbacks", self._core_fallbacks)
- context_window_fallbacks: Final[list | None] = initial_kwargs.get(
- "context_window_fallbacks", self._core_context_window_fallbacks
- )
- content_policy_fallbacks: Final[list | None] = initial_kwargs.get(
- "content_policy_fallbacks", self._core_content_policy_fallbacks
- )
- initial_kwargs["original_function"] = self._acompletion
- continuation_messages: Final = (
- self._build_completion_continuation_input(messages, e.generated_content)
- if continue_after_content
- else None
- )
- continuation_output: Final = (
- ContinuationOutput(
- e.generated_content,
- continuation_messages[-1],
- model_group,
- complete_response_object,
- checks_post_call_rules=initial_kwargs.get(MID_STREAM_CONTINUATION_KWARG)
- is not MID_STREAM_CONTINUATION_MARKER,
- )
- if continuation_messages is not None
- else None
- )
- cut_partial: Final = (
- Router._price_cut_partial(model_response, complete_response_object)
- if continuation_output is not None
- else None
- )
- if continue_after_content:
- emitted_tokens: Final = int(
- getattr(complete_response_object_usage, "completion_tokens", 0) or 0
- )
- reduced_ceilings: Final = continuation_output_ceilings(initial_kwargs, emitted_tokens)
- if reduced_ceilings is None or continuation_messages is None:
- self._raise_original_mid_stream_error(e)
- await model_response.wait_for_failure_logging(timeout=MID_STREAM_FAILURE_LOG_WAIT_SECONDS)
- initial_kwargs.update(reduced_ceilings)
- initial_kwargs["messages"] = continuation_messages
- initial_kwargs[MID_STREAM_CONTINUATION_KWARG] = MID_STREAM_CONTINUATION_MARKER
- else:
- initial_kwargs["messages"] = messages
- self._update_kwargs_before_fallbacks(model=model_group, kwargs=initial_kwargs)
- fallback_response = await self.async_function_with_fallbacks_common_utils(
- e=e.original_exception
- if isinstance(e.original_exception, litellm.ContextWindowExceededError)
- else e,
- disable_fallbacks=fallbacks_disabled_for_request(initial_kwargs),
- fallbacks=fallbacks,
- context_window_fallbacks=context_window_fallbacks,
- content_policy_fallbacks=content_policy_fallbacks,
- model_group=model_group,
- args=(),
- kwargs=initial_kwargs,
- include_fallback_errors=initial_kwargs.get("include_fallback_errors", False) is True,
- )
-
- # If fallback returns a streaming response, iterate over it
- if hasattr(fallback_response, "__aiter__"):
- Router._carry_partial_spend_to_fallback(fallback_response, cut_partial)
- prepared_fallback_hidden_params = Router._adopt_fallback_response_headers(
- wrapper_ref, fallback_response
- )
- fallback_headers_are_settled = False
- fallback_items: Final = (
- continuation_output.splice(fallback_response)
- if continuation_output is not None
- else fallback_response
- )
- async for fallback_item in fallback_items:
- if not fallback_headers_are_settled:
- fallback_headers_are_settled = True
- # a fallback that failed over again only repoints itself once it yields
- prepared_fallback_hidden_params = Router._adopt_fallback_response_headers(
- wrapper_ref, fallback_response
- )
- Router._apply_fallback_hidden_params_to_item(fallback_item, prepared_fallback_hidden_params)
- if (
- fallback_item
- and isinstance(fallback_item, ModelResponseStream)
- and hasattr(fallback_item, "usage")
- ):
- self._combine_fallback_usage(
- fallback_item,
- complete_response_object_usage if continuation_output is not None else None,
- )
- yield fallback_item
- else:
- # If fallback returns a non-streaming response, yield None
- yield None
-
- except Exception as fallback_error:
- # If fallback also fails, log and re-raise original error
- verbose_router_logger.error("Fallback also failed: %s", fallback_error)
- # No fallback handled the mid-stream error, so surface the
- # real provider exception (e.g. RateLimitError) instead of
- # leaking the internal MidStreamFallbackError to the client
- if (
- isinstance(fallback_error, MidStreamFallbackError)
- and fallback_error.original_exception is not None
- ):
- raise fallback_error.original_exception from fallback_error
- raise fallback_error
- finally:
- # Close the underlying streams to release HTTP connections
- # back to the connection pool when the generator is closed
- # (e.g. on client disconnect).
- # Shield from anyio cancellation so the awaits can complete.
- with anyio.CancelScope(shield=True):
- await close_model_response()
- await held_slot.aclose()
- if fallback_response is not None and hasattr(fallback_response, "aclose"):
- try:
- await fallback_response.aclose()
- except BaseException as close_error:
- verbose_router_logger.debug(
- "stream_with_fallbacks: error closing fallback_response: %s",
- close_error,
- )
-
- wrapped_response: Final = FallbackStreamWrapper(stream_with_fallbacks())
- # weak, so the generator closing over it does not keep the wrapper out of
- # refcount teardown and delay the `finally` that releases the deployment slot
- wrapper_ref: Final[weakref.ref[FallbackAwareStreamWrapper]] = weakref.ref(wrapped_response)
- return wrapped_response
-
- @staticmethod
- def _extract_partial_responses_usage(
- source_iterator: "BaseResponsesAPIStreamingIterator",
- ) -> Optional["ResponseAPIUsage"]:
- """
- Best-effort: pull partial token usage from a Responses-API streaming
- iterator that errored mid-stream, normalized to ResponseAPIUsage so
- the caller can combine without crossing token-naming conventions.
-
- Two sources, in priority order:
- 1. The bridge path (LiteLLMCompletionStreamingIterator) accumulates
- chat-completion chunks while streaming — feed them through
- stream_chunk_builder to recover chat Usage, then translate
- (prompt_tokens → input_tokens, completion_tokens → output_tokens).
- 2. The native path (ResponsesAPIStreamingIterator) only has a
- completed_response object if the stream reached
- RESPONSE_COMPLETED before erroring — uncommon mid-stream but
- worth checking. Already ResponseAPIUsage-shaped.
-
- Returns None when no partial usage is recoverable.
- """
- from litellm.responses.litellm_completion_transformation.streaming_iterator import (
- LiteLLMCompletionStreamingIterator,
- )
- from litellm.types.llms.openai import (
- ResponseAPIUsage,
- ResponseCompletedEvent,
- ResponseFailedEvent,
- ResponseIncompleteEvent,
- )
-
- # Bridge subclass is the only iterator that accumulates chat-completion
- # chunks. isinstance narrows the type so we can read the attribute
- # directly instead of getattr-ing on the base class.
- if isinstance(source_iterator, LiteLLMCompletionStreamingIterator):
- chunks: Final = source_iterator.collected_chat_completion_chunks
- if chunks:
- try:
- from litellm.main import stream_chunk_builder
-
- built: Final = stream_chunk_builder(chunks=chunks)
- # stream_chunk_builder returns ModelResponse |
- # TextCompletionResponse | None. ModelResponse sets .usage
- # in __init__ rather than declaring it as a class field, so
- # static narrowing doesn't expose it. Mirror the sync path
- # (_completion_streaming_iterator) and pull via getattr.
- chat: Final[object | None] = getattr(built, "usage", None) if built is not None else None
- if chat is not None:
- # getattr-with-default because the test path may
- # substitute a SimpleNamespace lacking some fields;
- # real Usage instances always have them.
- prompt: Final = int(getattr(chat, "prompt_tokens", 0) or 0)
- completion: Final = int(getattr(chat, "completion_tokens", 0) or 0)
- total: Final = int(getattr(chat, "total_tokens", prompt + completion) or (prompt + completion))
- return ResponseAPIUsage(
- input_tokens=prompt,
- output_tokens=completion,
- total_tokens=total,
- )
- except Exception:
- # Builder is best-effort — fall through to native path.
- pass
-
- # Native path: completed_response is set only if RESPONSE_COMPLETED
- # arrived before the error (uncommon mid-stream but worth checking).
- # Already ResponseAPIUsage-shaped — return as-is.
- completed: Final = source_iterator.completed_response
- if isinstance(
- completed,
- (ResponseCompletedEvent, ResponseFailedEvent, ResponseIncompleteEvent),
- ):
- return completed.response.usage
- return None
-
- @staticmethod
- def _combine_responses_fallback_usage(
- fallback_item: "BaseLiteLLMOpenAIResponseObject",
- partial_usage: "ResponseAPIUsage",
- ) -> None:
- """
- Merge partial-stream usage with fallback-stream usage on a
- Responses-API streaming event.
-
- Only mutates events that carry a `response` with a `usage` field
- (response.completed / response.failed / response.incomplete). Other
- events pass through unchanged.
-
- Both inputs are ResponseAPIUsage-shaped (see
- _extract_partial_responses_usage which normalizes the bridge path),
- so we can sum input_tokens / output_tokens / total_tokens directly
- and produce a clean ResponseAPIUsage — no token-naming split, no
- setattr bypass.
- """
- from litellm.types.llms.openai import (
- ResponseAPIUsage,
- ResponseCompletedEvent,
- ResponseFailedEvent,
- ResponseIncompleteEvent,
- )
-
- if not isinstance(
- fallback_item,
- (ResponseCompletedEvent, ResponseFailedEvent, ResponseIncompleteEvent),
- ):
- return
- response: Final = fallback_item.response
- if response.usage is None:
- return
-
- fb: Final = response.usage
- response.usage = ResponseAPIUsage(
- input_tokens=(partial_usage.input_tokens or 0) + (fb.input_tokens or 0),
- output_tokens=(partial_usage.output_tokens or 0) + (fb.output_tokens or 0),
- total_tokens=(partial_usage.total_tokens or 0) + (fb.total_tokens or 0),
- )
-
- @staticmethod
- def _raise_original_mid_stream_error(e: "MidStreamFallbackError") -> NoReturn:
- """Surface the provider error the stream wrapper carried instead of the internal MidStreamFallbackError."""
- if e.original_exception is not None:
- raise e.original_exception from e
- raise e
-
- def _active_default_litellm_params(self) -> Mapping[str, object]:
- default_params: Final = _REQUEST_KWARGS_ADAPTER.validate_python(self._core_default_litellm_params)
- return {key: value for key, value in default_params.items() if value is not None}
-
- def _params_sent_to_deployment(
- self, request_kwargs: Mapping[str, object], deployment: Deployment | None
- ) -> Mapping[str, object]:
- """The request as the deployment received it, merged in the order _update_kwargs_with_deployment applies."""
- deployment_params: Final = deployment.litellm_params.model_dump(exclude_none=True) if deployment else {}
- defaults: Final = self._active_default_litellm_params()
- deployment_tool_choice: Final = (
- {"tool_choice": deployment_params["tool_choice"]}
- if deployment_params.get("tool_choice") is not None
- else {}
- )
- return MappingProxyType({**deployment_params, **defaults, **deployment_tool_choice, **request_kwargs})
-
- def _names_deployment_that_cannot_continue(self, target: object) -> bool:
- name: Final = fallback_target_name(target)
- if name is None or name in self._core_model_names or not self._core_has_model_id(name):
- return False
- deployment: Final = self._core_get_deployment(model_id=name)
- return deployment is not None and not deployment_can_continue(deployment.model_dump(exclude_none=True))
-
- def _mid_stream_continuation_eligible(
- self,
- e: "MidStreamFallbackError",
- request_kwargs: Mapping[str, object],
- cut_deployment_id: object = None,
- ) -> bool:
- """Whether a stream that broke after plain assistant text may continue via prefill."""
- if not self.enable_mid_stream_fallback_continuation:
- return False
- if not e.generated_content or e.emitted_disqualifying_content:
- return False
- if isinstance(e.original_exception, litellm.ContentPolicyViolationError):
- return False
- cut_deployment: Final = (
- self._core_get_deployment(model_id=cut_deployment_id) if isinstance(cut_deployment_id, str) else None
- )
- if isinstance(cut_deployment_id, str) and cut_deployment is None:
- return False
- if constrains_continuation(self._params_sent_to_deployment(request_kwargs, cut_deployment)):
- return False
- requested_group: Final = request_kwargs.get("model")
- lookup_groups: Final = fallback_lookup_groups(
- request_kwargs, requested_group if isinstance(requested_group, str) else None
- )
- targets: Final = continuation_fallback_targets(
- request_kwargs.get("fallbacks", self._core_fallbacks), lookup_groups
- )
- if any(fallback_target_overrides_request_params(target) for target in targets):
- return False
- if any(self._names_deployment_that_cannot_continue(target) for target in targets):
- return False
- return not any(
- isinstance(callback, CustomGuardrail)
- and callback._event_hook_is_event_type( # pyright: ignore[reportPrivateUsage] # the only check of a guardrail's hooks
- GuardrailEventHooks.post_call
- )
- for callback in litellm.callbacks
- )
-
- @staticmethod
- def _build_completion_continuation_input(
- messages: Sequence[Mapping[str, object]],
- generated_content: str,
- ) -> Sequence[Mapping[str, object]] | None:
- """Append the partial output as an assistant prefill, or extend a trailing `prefix: True` assistant turn in
- place so the request never ends in two assistant messages. None when the request ends in a plain assistant
- turn, in a prefill turn whose content has a shape this cannot extend, or when the cut came mid-echo of the
- prefill."""
- last: Final = messages[-1] if messages else None
- if last is None or last.get("role") != "assistant":
- return [*messages, {"role": "assistant", "content": generated_content, "prefix": True}]
- if last.get("prefix") is not True:
- return None
- new_text: Final = continued_text(last, generated_content)
- if new_text is None:
- return None
- content: Final = last.get("content")
- if content is None or isinstance(content, str):
- return [*messages[:-1], {**last, "content": f"{content or ''}{new_text}", "prefix": True}]
- try:
- blocks: Final = _CONTENT_BLOCKS_ADAPTER.validate_python(content)
- except ValidationError:
- return None
- extended: Final = _extend_trailing_text_block(blocks, new_text)
- return [*messages[:-1], {**last, "content": extended, "prefix": True}]
-
- async def _aresponses_streaming_iterator(
- self,
- response: "BaseResponsesAPIStreamingIterator",
- initial_kwargs: dict[str, object],
- ) -> "BaseResponsesAPIStreamingIterator":
- """
- Wrap a Responses-API streaming iterator so MidStreamFallbackError
- triggers the Router's fallback chain (parity with
- _acompletion_streaming_iterator for the chat-completions path).
-
- The Responses-API streaming path goes through
- _ageneric_api_call_with_fallbacks rather than _acompletion, so the
- returned iterator is never wrapped by the chat completions
- fallback handler. Without this wrapper, MidStreamFallbackError
- raised mid-stream from the underlying CustomStreamWrapper (used by
- LiteLLMCompletionStreamingIterator when the Responses API is
- served via the completion bridge) propagates unhandled and the
- configured cross-provider fallback never fires.
-
- Full parity with the chat-completions path:
- - Pre-first-chunk: retry with the original input unchanged.
- - Partial content: continue from the generated text, as an
- assistant prefill on a fallback that takes one and as assistant
- history plus a user request to continue on any other. A request
- whose output is constrained (a JSON schema, a forced tool), or a
- stream that began a tool call, is not continued, and the primary's
- error surfaces instead.
- - Usage combining: merge partial-stream usage onto the fallback's
- response.completed event so accounting reflects both attempts.
- - Stream cleanup: shielded aclose() on both source and fallback
- iterators on terminate.
- """
- from litellm.exceptions import MidStreamFallbackError
- from litellm.responses.streaming_iterator import (
- BaseResponsesAPIStreamingIterator,
- get_openai_response_types,
- )
-
- source_iterator: Final = response
-
- # Pre-resolve the set of terminal stream event types so the
- # per-chunk type check inside FallbackResponsesStreamWrapper
- # stays cheap; mirrors the source-iterator filter at
- # responses/streaming_iterator.py:243-247.
- _openai_types: Final = get_openai_response_types()
- _RESPONSES_TERMINAL_EVENT_TYPES: Final = (
- _openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED,
- _openai_types.ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE,
- _openai_types.ResponsesAPIStreamEvents.RESPONSE_FAILED,
- )
-
- class FallbackResponsesStreamWrapper(BaseResponsesAPIStreamingIterator):
- """
- Subclasses BaseResponsesAPIStreamingIterator only for isinstance
- compatibility (proxy + interactions code paths check the type).
- Bypasses the parent constructor and delegates iteration to an
- async generator.
- """
-
- fallback_headers_adopted: bool = False
-
- def __init__(self, async_generator: AsyncGenerator):
- import time
- from datetime import datetime
-
- self._async_generator = async_generator
- self._billing_source: object = source_iterator
- # Mirror every attribute BaseResponsesAPIStreamingIterator.__init__
- # would have set. The wrapper bypasses super().__init__ (it has no
- # httpx.Response of its own and no provider config to drive), so
- # we copy from source_iterator where applicable and use safe
- # defaults elsewhere. This keeps inherited methods (e.g.
- # _check_max_streaming_duration, _handle_failure) safe to call.
- #
- # The bridge path (LiteLLMCompletionStreamingIterator used by
- # Anthropic/Bedrock/Vertex) does not call super().__init__ and
- # is missing many of these attributes — use getattr fallbacks
- # so wrapper construction never raises AttributeError. The
- # bridge stores the logging object as `litellm_logging_obj`.
- # base class declares non-Optional types for these
- # fields but the bridge path (LiteLLMCompletionStreamingIterator)
- # can legitimately omit them at runtime — keep the None
- # fallback. Same lines passed mypy on the pre-fix file
- # because the surrounding function body wasn't fully
- # type-narrowed; the new typed terminal-event tuple above
- # is what made these surface.
- self.response = getattr(source_iterator, "response", None)
- self.model = getattr(source_iterator, "model", None)
- self.logging_obj = getattr(
- source_iterator,
- "logging_obj",
- getattr(source_iterator, "litellm_logging_obj", None),
- )
- self.finished = False
- self.responses_api_provider_config = getattr(source_iterator, "responses_api_provider_config", None)
- self.completed_response = None
- self.start_time = getattr(source_iterator, "start_time", datetime.now())
- self._failure_handled = False
- self._yielded_first_chunk = False
- self._generated_content = ""
- self._completed_response_cached = False
- self._completed_response_logged = False
- self._completed_response_cache_hit = None
- self._persist_completed_response_before_logging = True
- self._stream_created_time = time.time()
- self.litellm_metadata = getattr(source_iterator, "litellm_metadata", None)
- self.custom_llm_provider = getattr(source_iterator, "custom_llm_provider", None)
- self.request_data = getattr(source_iterator, "request_data", {}) or {}
- self.call_type = getattr(source_iterator, "call_type", None)
- # Preserve hidden params so response headers (model_id,
- # api_base, additional_headers) keep flowing.
- self._hidden_params = dict(getattr(source_iterator, "_hidden_params", None) or {})
-
- def adopt_fallback_headers(self, fallback_response: object) -> tuple[dict[str, object], dict[str, object]]:
- prepared: Final = Router._prepare_fallback_hidden_params(fallback_response)
- self._billing_source = fallback_response
- self._hidden_params = {**prepared[0], "additional_headers": prepared[1]}
- self.fallback_headers_adopted = True
- return prepared
-
- def partial_response_on_disconnect(self) -> "ResponseIncompleteEvent | None":
- """
- The event to bill on a client disconnect. After a mid-stream hop the primary
- stream's relayed tokens are folded into the fallback's event, the way
- _combine_responses_fallback_usage does for a natural end of stream.
- """
- from litellm.responses.streaming_iterator import partial_responses_incomplete_event_on_disconnect
-
- current: Final = partial_responses_incomplete_event_on_disconnect(self._billing_source)
- if self._billing_source is source_iterator:
- return current
- primary: Final = partial_responses_incomplete_event_on_disconnect(source_iterator)
- if current is None or primary is None:
- return current or primary
- if primary.response.usage is not None:
- Router._combine_responses_fallback_usage(current, primary.response.usage)
- return current
-
- def __aiter__(self):
- return self
-
- @property
- def yielded_first_chunk(self) -> bool:
- return self._yielded_first_chunk
-
- async def __anext__(self):
- try:
- chunk: Final = await self._async_generator.__anext__()
- except StopAsyncIteration:
- # The inner generator is exhausted. If we never sniffed a
- # terminal event off a chunk (the bridge path emits the
- # final response.completed via common_done_event_logic,
- # which raises StopAsyncIteration after returning it),
- # fall back to whatever the source iterator latched so
- # the proxy's container-ownership hook still sees a
- # completed_response instead of logging a spurious
- # "no completed_response" warning.
- if self.completed_response is None:
- self.completed_response = getattr(source_iterator, "completed_response", None)
+ return object.__getattribute__(self, name)
+ except AttributeError:
+ if _is_core(self):
raise
- # Sniff the terminal stream event off each forwarded chunk
- # so ``self.completed_response`` is populated regardless of
- # which inner iterator produced it (source_iterator,
- # fallback_iterator, or any future wrapper). Without this
- # the proxy's container-ownership hook (which reads
- # ``getattr(stream_response, "completed_response", None)``
- # via extract_completed_responses_response) silently
- # records nothing on streaming /v1/responses calls — every
- # follow-up /v1/containers//files call then 403s for
- # the very key that created the container (#30210).
- if self.completed_response is None and getattr(chunk, "type", None) in _RESPONSES_TERMINAL_EVENT_TYPES:
- self.completed_response = chunk
- self._yielded_first_chunk = True
- return chunk
-
- async def aclose(self):
- # async generators always expose aclose — no defensive check needed.
- await self._async_generator.aclose()
-
- async def stream_with_fallbacks():
- held_lifecycle_events: tuple[object, ...] = () # rebind-ok: flushed at first output, dropped on fallback
- response_id_exposed = False # rebind-ok: set once, before an early-released lifecycle event is yielded
+ facade: Final = object.__getattribute__(self, "__dict__")
+ core: Final = facade.get("_core")
+ if core is None:
+ pending: Final = facade.get("_pending", {})
+ if name in pending:
+ return pending[name]
+ raise AttributeError(name)
try:
- async for item in cast( # cast-ok: response iterators are async streams
- AsyncIterator[object], source_iterator
- ):
- if not response_id_exposed and self._aresponses_background_stream_can_release(
- item, source_iterator, initial_kwargs
- ):
- response_id_exposed = True
- if not response_id_exposed and _responses_stream_holds_event(item, len(held_lifecycle_events)):
- held_lifecycle_events = (*held_lifecycle_events, item)
- continue
- for held_event in held_lifecycle_events:
- yield held_event
- held_lifecycle_events = ()
- yield item
- for held_event in held_lifecycle_events:
- yield held_event
- except (MidStreamFallbackError, litellm.ContextWindowExceededError) as stream_error:
- e: Final = (
- _context_window_stream_fallback_error(
- stream_error,
- source_iterator.model or "",
- source_iterator.custom_llm_provider or "",
- wrapper.yielded_first_chunk,
- )
- if isinstance(stream_error, litellm.ContextWindowExceededError)
- or isinstance(stream_error.original_exception, litellm.ContextWindowExceededError)
- else stream_error
- )
- if response_id_exposed:
- self._raise_released_stream_error(e, initial_kwargs)
- async with contextlib.aclosing(
- self._aresponses_fallback_attempt(
- e, source_iterator, initial_kwargs, wrapper.adopt_fallback_headers, held_lifecycle_events
- )
- ) as fallback_stream:
- async for fallback_item in fallback_stream:
- yield fallback_item
- except Exception:
- for held_event in held_lifecycle_events:
- yield held_event
- raise
- finally:
- with anyio.CancelScope(shield=True):
- if hasattr(source_iterator, "aclose"):
- try:
- await source_iterator.aclose()
- except Exception as exc:
- verbose_router_logger.debug(
- "stream_with_fallbacks(aresponses): error closing source: %s",
- exc,
- )
-
- wrapper: Final = FallbackResponsesStreamWrapper(stream_with_fallbacks())
- return wrapper
-
- def _aresponses_background_stream_can_release(
- self,
- item: object,
- source_iterator: "BaseResponsesAPIStreamingIterator",
- kwargs: Mapping[str, object],
- ) -> bool:
- from litellm.responses.streaming_iterator import ResponsesAPIStreamingIterator
-
- if (
- type(source_iterator) is not ResponsesAPIStreamingIterator
- or source_iterator.custom_llm_provider != "openai"
- ):
- return False
- if not _responses_event_announces_background_run(item):
- return False
- model_group: Final = kwargs.get("model")
- return isinstance(model_group, str) and not self._stream_can_fall_back(model_group, kwargs)
-
- def _raise_released_stream_error(
- self, e: "MidStreamFallbackError", initial_kwargs: Mapping[str, object]
- ) -> NoReturn:
- original: Final = e.original_exception
- model_group: Final = initial_kwargs.get("model")
- if (
- litellm.expose_router_debug_in_errors
- and isinstance(original, litellm.ContentPolicyViolationError)
- and isinstance(model_group, str)
- and not fallbacks_disabled_for_request(initial_kwargs)
- and _mid_stream_error_before_output(e)
- ):
- hop_depth: Final = initial_kwargs.get("fallback_depth")
- original.message += format_content_policy_no_fallback_note(
- model_group,
- fallback_lookup_groups(initial_kwargs, model_group),
- _request_fallback_list(
- initial_kwargs,
- "content_policy_fallbacks",
- cast( # cast-ok: untyped router attribute
- "list[object] | None", self._core_content_policy_fallbacks
- ),
- ),
- _request_fallback_list(
- initial_kwargs,
- "fallbacks",
- cast("list[object] | None", self._core_fallbacks), # cast-ok: untyped router attribute
- ),
- isinstance(hop_depth, int) and hop_depth > 0,
- )
- Router._raise_original_mid_stream_error(e)
-
- def _responses_continuation(
- self,
- e: "MidStreamFallbackError",
- source_iterator: "BaseResponsesAPIStreamingIterator",
- request_kwargs: Mapping[str, object],
- ) -> ResponsesContinuation | None:
- """What a fallback continues from once a Responses stream broke after text, raising the primary's error
- when the request constrains its output or the stream began a tool call, since a continued JSON document or
- tool call would not parse, and when a content filter cut it, so no other model finishes filtered text."""
- if _mid_stream_error_before_output(e):
- return None
- if source_iterator.started_tool_call or isinstance(e.original_exception, litellm.ContentPolicyViolationError):
- Router._raise_original_mid_stream_error(e)
- cut_deployment_id: Final = (get_hidden_params(source_iterator) or {}).get("model_id")
- cut_deployment: Final = (
- self._core_get_deployment(model_id=cut_deployment_id) if isinstance(cut_deployment_id, str) else None
- )
- if constrains_responses_continuation(self._params_sent_to_deployment(request_kwargs, cut_deployment)):
- Router._raise_original_mid_stream_error(e)
- earlier: Final = request_kwargs.get(RESPONSES_CONTINUATION_KWARG)
- if isinstance(earlier, ResponsesContinuation):
- return earlier.extended(e.generated_content)
- return ResponsesContinuation(request_kwargs.get("input"), e.generated_content)
-
- @staticmethod
- def _responses_continuation_ceilings(
- e: "MidStreamFallbackError", request_kwargs: Mapping[str, object]
- ) -> Mapping[str, object]:
- """The caller's output ceilings less what the cut stream already emitted, raising the primary's error once
- nothing is left."""
- emitted_tokens: Final = token_counter(model=e.model or "", text=e.generated_content)
- reduced: Final = continuation_output_ceilings(request_kwargs, emitted_tokens)
- if reduced is None:
- Router._raise_original_mid_stream_error(e)
- return reduced
-
- async def _aresponses_fallback_attempt(
- self,
- e: "MidStreamFallbackError",
- source_iterator: "BaseResponsesAPIStreamingIterator",
- initial_kwargs: dict[str, Any], # mutable-ok: mutated in-place before re-entering the fallback chain
- adopt_headers: Callable[[object], tuple[dict[str, object], dict[str, object]]], # mutable-ok: hidden params
- held_lifecycle_events: tuple[object, ...],
- ) -> AsyncGenerator[object, None]:
- """
- Re-enters the Router's fallback chain for a mid-stream Responses API error and yields
- whatever the fallback attempt produces. The lifecycle events the primary stream held
- back reach the client only when no fallback lands, so the client sees exactly one
- response announced, the one whose id completes. Split out of
- _aresponses_streaming_iterator to keep each function's cyclomatic complexity within
- the repo's C901 budget.
- """
- from litellm.exceptions import MidStreamFallbackError
-
- continuation: Final = self._responses_continuation(e, source_iterator, initial_kwargs)
- reduced_ceilings: Final[Mapping[str, object]] = (
- Router._responses_continuation_ceilings(e, initial_kwargs) if continuation is not None else {}
- )
- seam: Final = ResponsesContinuationSeam(continuation.emitted_text if continuation is not None else "")
- partial_usage: Final = Router._extract_partial_responses_usage(source_iterator)
- fallback_response = None # rebind-ok: pre-init so finally can close it if a fallback was actually attempted
- fallback_yielded = False # rebind-ok: flipped on the first fallback item so a fallback that dies before its first event still replays the primary's held announcement
- try:
- model_group: Final = cast(str, initial_kwargs.get("model")) # cast-ok: model group
- fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the common_utils list|None param
- "fallbacks", self._core_fallbacks
- )
- context_window_fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the param below
- "context_window_fallbacks", self._core_context_window_fallbacks
- )
- content_policy_fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the param below
- "content_policy_fallbacks", self._core_content_policy_fallbacks
- )
- initial_kwargs["original_function"] = ( # rebind-ok: the fallback chain re-enters on the same kwargs
- self._ageneric_api_call_with_fallbacks_responses_attempt
- )
- if continuation is not None:
- initial_kwargs.update(reduced_ceilings)
- initial_kwargs[RESPONSES_CONTINUATION_KWARG] = continuation # rebind-ok: shaped per fallback deployment
- initial_kwargs["input"] = responses_continuation_input(continuation) # rebind-ok: fallback hop input
- # The Responses-API path stores observability metadata
- # under "litellm_metadata" (not the default "metadata") —
- # see _ageneric_api_call_with_fallbacks. Mirroring that
- # here ensures model_group, model_group_alias, and trace
- # ids land in the same key litellm.aresponses reads from.
- self._update_kwargs_before_fallbacks(
- model=model_group,
- kwargs=initial_kwargs,
- metadata_variable_name="litellm_metadata",
- )
- # The content-policy dispatch branch matches on the trigger's own type, so a refusal's
- # MidStreamFallbackError envelope is unwrapped here or the wrong fallback list is consulted.
- fallback_trigger: Final[Exception] = (
- e.original_exception
- if isinstance(
- e.original_exception, (litellm.ContentPolicyViolationError, litellm.ContextWindowExceededError)
- )
- else e
- )
- fallback_response = await self.async_function_with_fallbacks_common_utils( # rebind-ok: set on success
- e=fallback_trigger,
- disable_fallbacks=fallbacks_disabled_for_request(initial_kwargs),
- fallbacks=fallbacks,
- context_window_fallbacks=context_window_fallbacks,
- content_policy_fallbacks=content_policy_fallbacks,
- model_group=model_group,
- args=(),
- kwargs=initial_kwargs,
- include_fallback_errors=initial_kwargs.get("include_fallback_errors", False) is True,
- )
- prepared_fallback_hidden_params: Final = adopt_headers(fallback_response)
- if hasattr(fallback_response, "__aiter__"):
- async for fallback_item in seam.splice(fallback_response):
- Router._apply_fallback_hidden_params_to_item(fallback_item, prepared_fallback_hidden_params)
- if partial_usage is not None:
- Router._combine_responses_fallback_usage(fallback_item, partial_usage)
- fallback_yielded = True
- yield fallback_item
- else:
- fallback_yielded = True # rebind-ok: see the pre-init above
- yield fallback_response
- except Exception as fallback_error:
- verbose_router_logger.error("Responses streaming fallback also failed: %s", fallback_error)
- if not fallback_yielded:
- for held_event in held_lifecycle_events:
- yield held_event
- if isinstance(fallback_error, MidStreamFallbackError) and fallback_error.original_exception is not None:
- raise fallback_error.original_exception from fallback_error
- raise
- finally:
- if fallback_response is not None and hasattr(fallback_response, "aclose"):
- with anyio.CancelScope(shield=True):
- try:
- await fallback_response.aclose()
- except Exception as exc:
- verbose_router_logger.debug(
- "stream_with_fallbacks(aresponses): error closing fallback: %s",
- exc,
- )
-
- def _completion_streaming_iterator(
- self,
- model_response: CustomStreamWrapper,
- messages: list[dict[str, str]],
- initial_kwargs: dict,
- first_chunk: _FirstChunk[Iterator[object]] | None = None,
- ) -> CustomStreamWrapper:
- """
- Sync equivalent of _acompletion_streaming_iterator.
-
- Wraps a sync streaming response so that MidStreamFallbackError
- (raised by CustomStreamWrapper.__next__) triggers the Router's
- fallback chain instead of surfacing directly to the caller.
- """
- from litellm.exceptions import MidStreamFallbackError
-
- class SyncFallbackStreamWrapper(FallbackAwareStreamWrapper):
- def __init__(self, sync_generator: Generator):
- super().__init__(
- completion_stream=sync_generator,
- model=model_response.model,
- custom_llm_provider=model_response.custom_llm_provider,
- logging_obj=model_response.logging_obj,
- _response_headers=getattr(model_response, "_response_headers", None),
- )
- self._sync_generator = sync_generator
- model_response_hidden_params: Final = get_hidden_params(model_response)
- if model_response_hidden_params is not None:
- self._hidden_params = dict(model_response_hidden_params)
-
- def __iter__(self):
- return self
-
- def __next__(self):
- return next(self._sync_generator)
-
- router_self: Final = self
-
- def stream_with_fallbacks():
- fallback_response = None
- try:
- for item in _chunks_with_first(model_response, first_chunk):
- yield item
- except (MidStreamFallbackError, litellm.ContextWindowExceededError) as stream_error:
- e: Final = (
- _context_window_stream_fallback_error(
- stream_error,
- getattr(model_response, "model", ""),
- model_response.custom_llm_provider or "",
- bool(getattr(model_response, "response_uptil_now", ""))
- or _stream_chunks_have_generated_content(model_response.chunks),
- )
- if isinstance(stream_error, litellm.ContextWindowExceededError)
- or isinstance(stream_error.original_exception, litellm.ContextWindowExceededError)
- else stream_error
- )
- if fallbacks_disabled_for_request(initial_kwargs) or (
- not e.is_pre_first_chunk
- and (e.generated_content or _stream_chunks_have_generated_content(model_response.chunks))
- ):
- if e.original_exception is not None:
- raise e.original_exception from e
+ return getattr(core, name)
+ except AttributeError as error:
+ if error.obj is not core or error.name != name:
raise
+ # What reading an attribute the router lacks raised before: it names the Router.
+ raise AttributeError(
+ f"'{type(self).__name__}' object has no attribute '{name}'", name=name, obj=self
+ ) from None
- try:
- model_group: Final = cast(str, initial_kwargs.get("model"))
- fallbacks: Final[list | None] = initial_kwargs.get("fallbacks", router_self._core_fallbacks)
- context_window_fallbacks: Final[list | None] = initial_kwargs.get(
- "context_window_fallbacks",
- router_self._core_context_window_fallbacks,
- )
- content_policy_fallbacks: Final[list | None] = initial_kwargs.get(
- "content_policy_fallbacks",
- router_self._core_content_policy_fallbacks,
- )
- initial_kwargs["original_function"] = router_self._completion
- initial_kwargs["messages"] = messages
- router_self._update_kwargs_before_fallbacks(model=model_group, kwargs=initial_kwargs)
- fallback_response = run_async_function(
- router_self.async_function_with_fallbacks_common_utils,
- e=e.original_exception
- if isinstance(e.original_exception, litellm.ContextWindowExceededError)
- else e,
- disable_fallbacks=fallbacks_disabled_for_request(initial_kwargs),
- fallbacks=fallbacks,
- context_window_fallbacks=context_window_fallbacks,
- content_policy_fallbacks=content_policy_fallbacks,
- model_group=model_group,
- args=(),
- kwargs=initial_kwargs,
- include_fallback_errors=initial_kwargs.get("include_fallback_errors", False) is True,
- )
-
- if hasattr(fallback_response, "__iter__"):
- prepared_fallback_hidden_params = Router._adopt_fallback_response_headers(
- wrapper_ref, fallback_response
- )
- fallback_headers_are_settled = False
- for fallback_item in cast(Iterable[object], fallback_response): # cast-ok: __iter__ was checked
- if not fallback_headers_are_settled:
- fallback_headers_are_settled = True
- # a fallback that failed over again only repoints itself once it yields
- prepared_fallback_hidden_params = Router._adopt_fallback_response_headers(
- wrapper_ref, fallback_response
- )
- Router._apply_fallback_hidden_params_to_item(fallback_item, prepared_fallback_hidden_params)
- if (
- fallback_item
- and isinstance(fallback_item, ModelResponseStream)
- and hasattr(fallback_item, "usage")
- ):
- router_self._combine_fallback_usage(fallback_item, None)
- yield fallback_item
- else:
- yield None
-
- except Exception as fallback_error:
- verbose_router_logger.error("Fallback also failed: %s", fallback_error)
- if (
- isinstance(fallback_error, MidStreamFallbackError)
- and fallback_error.original_exception is not None
- ):
- raise fallback_error.original_exception from fallback_error
- raise fallback_error
- finally:
- if hasattr(model_response, "close"):
- try:
- model_response.close()
- except BaseException as close_err:
- verbose_router_logger.debug(
- "stream_with_fallbacks: error closing model_response: %s",
- close_err,
- )
- if fallback_response is not None and hasattr(fallback_response, "close"):
- try:
- fallback_response.close()
- except BaseException as close_err:
- verbose_router_logger.debug(
- "stream_with_fallbacks: error closing fallback_response: %s",
- close_err,
- )
-
- wrapped_response: Final = SyncFallbackStreamWrapper(stream_with_fallbacks())
- # weak, for the same reason as the async twin
- wrapper_ref: Final[weakref.ref[FallbackAwareStreamWrapper]] = weakref.ref(wrapped_response)
- return wrapped_response
-
- async def _silent_experiment_acompletion(self, silent_model: str, messages: Sequence[Mapping[str, str]], **kwargs):
- """
- Run a silent experiment in the background.
- """
- try:
- # Prevent infinite recursion if silent model also has a silent model
- if kwargs.get("metadata", {}).get("is_silent_experiment", False):
+ def __setattr__(self, name: str, value: object) -> None:
+ if _is_core(self):
+ super().__setattr__(name, value)
return
-
- messages = copy.deepcopy(messages)
-
- verbose_router_logger.info("Starting silent experiment for model %s", silent_model)
-
- silent_kwargs: Final = self._get_silent_experiment_kwargs(**kwargs)
- # Override model_group to correctly attribute metrics to the silent model
- silent_kwargs["metadata"]["model_group"] = silent_model
-
- # Trigger the silent request
- await self._run_silent_experiment(silent_model, messages, silent_kwargs)
- except Exception as e:
- verbose_router_logger.error("Silent experiment failed for model %s: %s", silent_model, e)
-
- async def _acompletion(
- self, model: str, messages: list[dict[str, str]], **kwargs
- ) -> ModelResponse | CustomStreamWrapper:
- """
- - Get an available deployment
- - call it with a semaphore over the call
- - semaphore specific to it's rpm
- - in the semaphore, make a check against it's local rpm before running
- """
- model_name = None
- deployment = None
- _timeout_debug_deployment_dict = {} # this is a temporary dict to debug timeout issues
- try:
- controls: Final = kwargs.pop(MID_STREAM_FALLBACK_CONTROLS_KEY, None)
- input_kwargs_for_streaming_fallback: Final = mid_stream_fallback_snapshot_kwargs(
- model=model, controls=controls, kwargs=kwargs
- )
-
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- start_time: Final = time.time()
- deployment = await self._core_async_get_available_deployment(
- model=model,
- messages=messages,
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
- self._drop_unsupported_classifier_reasoning_effort(
- deployment=cast(DeploymentTypedDict, deployment), # cast-ok: selection returns a router deployment
- model=model,
- kwargs=kwargs,
- )
-
- _timeout_debug_deployment_dict = deployment
- end_time: Final = time.time()
- _duration: Final = end_time - start_time
- asyncio.create_task(
- self.service_logger_obj.async_service_success_hook(
- service=ServiceTypes.ROUTER,
- duration=_duration,
- call_type="async_get_available_deployment",
- start_time=start_time,
- end_time=end_time,
- parent_otel_span=get_parent_otel_span_from_kwargs(kwargs),
- )
- )
-
- # debug how often this deployment picked
-
- self._track_deployment_metrics(deployment=deployment, parent_otel_span=parent_otel_span)
-
- # Check for silent model experiment
- # Make a local copy of litellm_params to avoid mutating the Router's state
- litellm_params: Final = self._deployment_params_with_request_reasoning_override(
- deployment["litellm_params"], kwargs
- )
- silent_model: Final = litellm_params.pop("silent_model", None)
-
- for silent_target in _silent_experiment_targets(silent_model):
- # Mirroring traffic to a secondary model
- # This is a silent experiment, so we don't want to block the primary request
- asyncio.create_task(
- self._silent_experiment_acompletion(
- silent_model=silent_target,
- messages=messages, # Use messages instead of *args
- **_silent_experiment_kwargs_snapshot(kwargs),
- )
- )
-
- kwargs.setdefault("messages", messages)
- self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, splits_stream_timeout=True)
- kwargs.pop("silent_model", None) # Ensure it's not in kwargs either
-
- model_name = litellm_params["model"]
-
- model_client: Final = self._get_async_openai_model_client(
- deployment=deployment,
- kwargs=kwargs,
- )
- self.total_calls[model_name] += 1
-
- input_kwargs: Final = without_router_only_kwargs(
- {
- **litellm_params,
- "messages": messages,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs,
- }
- )
-
- logging_obj: Final[LiteLLMLogging | None] = kwargs.get("litellm_logging_obj", None)
-
- max_parallel_requests_limit: Final = self._get_client(
- deployment=deployment,
- kwargs=kwargs,
- client_type="max_parallel_requests",
- )
- compacted_input: Final = await compact_to_fit(self._core_ctx, deployment, input_kwargs, "chat")
- async with contextlib.AsyncExitStack() as deployment_slot:
- if isinstance(max_parallel_requests_limit, MaxParallelRequestsLimit):
- deployment_slot.enter_context(max_parallel_requests_limit)
- await self.async_routing_strategy_pre_call_checks(
- deployment=deployment,
- logging_obj=logging_obj,
- parent_otel_span=parent_otel_span,
- )
- response = await litellm.acompletion(**compacted_input)
-
- ## CHECK CONTENT FILTER ERROR ##
- if isinstance(response, ModelResponse):
- _should_raise = self._should_raise_content_policy_error(
- model=model, response=response, kwargs=kwargs
- )
- if _should_raise:
- raise litellm.ContentPolicyViolationError(
- message="Response output was blocked.",
- model=model,
- llm_provider="",
- )
-
- if (
- isinstance(response, CustomStreamWrapper)
- and response.completion_stream is None
- and response.make_call is not None
- ):
- await response.fetch_stream()
- first_chunk: Final = (
- await _aread_first_chunk(response) if isinstance(response, CustomStreamWrapper) else None
- )
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.acompletion(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
- # debug how often this deployment picked
- self._track_deployment_metrics(
- deployment=deployment,
- response=response,
- parent_otel_span=parent_otel_span,
- )
-
- if isinstance(response, CustomStreamWrapper):
- return await self._acompletion_streaming_iterator(
- model_response=response,
- messages=messages,
- initial_kwargs=input_kwargs_for_streaming_fallback,
- deployment_slot=deployment_slot.pop_all(),
- first_chunk=first_chunk,
- )
-
- return response
- except litellm.Timeout as e:
- deployment_litellm_params: Final[Mapping[str, object]] = _timeout_debug_deployment_dict.get(
- "litellm_params", {}
- )
- if litellm.expose_router_debug_in_errors:
- e.message += (
- f"\n\nDeployment Info: request_timeout: {deployment_litellm_params.get('request_timeout')}"
- f"\ntimeout: {deployment_litellm_params.get('timeout')}"
- f"\nstream_timeout: {deployment_litellm_params.get('stream_timeout')}"
- )
- # Set per-deployment num_retries on exception for retry logic
- if deployment is not None:
- self._set_deployment_num_retries_on_exception(e, deployment)
- self._stamp_failed_deployment_id_with_effective_model_info(e, deployment, kwargs)
- raise e
- except Exception as e:
- verbose_router_logger.info("litellm.acompletion(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
- if model_name is not None:
- self.fail_calls[model_name] += 1
- # Set per-deployment num_retries on exception for retry logic
- if deployment is not None:
- self._set_deployment_num_retries_on_exception(e, deployment)
- self._stamp_failed_deployment_id_with_effective_model_info(e, deployment, kwargs)
- raise e
-
- def _update_kwargs_before_fallbacks(
- self,
- model: str,
- kwargs: dict,
- metadata_variable_name: str | None = "metadata",
- ) -> None:
- """
- Adds/updates to kwargs:
- - litellm_trace_id
- - metadata
-
- num_retries is deliberately left as the caller passed it, with no default filled in.
- async_function_with_retries resolves the router/global default itself, and it can only
- honour the precedence request > deployment litellm_params > litellm_settings while an
- absent num_retries still means "the caller did not ask for one".
- """
- kwargs.setdefault("litellm_trace_id", str(uuid.uuid4()))
- model_group_alias: str | None = None
- if self._core_get_model_from_alias(model=model):
- model_group_alias = model
- kwargs.setdefault(metadata_variable_name, {}).update(
- {"model_group": model, "model_group_alias": model_group_alias}
- )
-
- def _set_deployment_num_retries_on_exception(self, exception: Exception, deployment: dict) -> None:
- """
- Set num_retries from deployment litellm_params on the exception.
-
- This allows the retry logic in async_function_with_retries to use
- per-deployment retry settings instead of the global setting.
- """
- # Only set if exception doesn't already have num_retries
- if hasattr(exception, "num_retries") and exception.num_retries is not None:
- return
-
- litellm_params: Final = deployment.get("litellm_params", {})
- dep_num_retries: Final = litellm_params.get("num_retries")
- if dep_num_retries is not None:
- try:
- exception.num_retries = int(dep_num_retries) # Handle both int and str
- except (ValueError, TypeError):
- pass # Skip if value can't be converted to int
-
- def _set_failed_deployment_id_on_exception(self, exception: Exception, deployment: Mapping[str, Any]) -> None:
- """
- Stamp the failed deployment's `model_info.id` on the exception so the
- fallback layer can exclude it from subsequent re-picks within the same
- request (used by weighted-routing failover).
-
- Idempotent: never overwrites an existing value, so the id of the
- deployment that *first* failed in a chain is preserved if multiple
- layers re-raise.
- """
- if getattr(exception, "failed_deployment_id", None):
- return
- deployment_id: Final = (deployment.get("model_info") or {}).get("id")
- if deployment_id:
- try:
- exception.failed_deployment_id = deployment_id
- except Exception:
- pass
-
- def _stamp_failed_deployment_id_with_effective_model_info(
- self, exception: Exception, deployment: Mapping[str, object], kwargs: Mapping[str, object]
- ) -> None:
- # A client-side-credential call gets a dynamic deployment id generated inside
- # _update_kwargs_with_deployment and stamped into kwargs["model_info"]; stamping
- # the static shared deployment's id instead would let one tenant's bad credentials
- # cool down the deployment every other tenant sharing this config relies on.
- effective_model_info: Final = kwargs.get("model_info") or deployment.get("model_info") or MappingProxyType({})
- self._set_failed_deployment_id_on_exception(exception, MappingProxyType({"model_info": effective_model_info}))
-
- @staticmethod
- def _stamp_retry_skip_deployment_id(exception: Exception, kwargs: Mapping[str, object]) -> None:
- effective_model_info: Final = kwargs.get("model_info")
- if not isinstance(effective_model_info, Mapping):
- return
- model_info: Final = _MODEL_INFO_ADAPTER.validate_python(effective_model_info)
- configured_deployment_id: Final = (
- model_info.get("original_model_id") if isinstance(exception, litellm.UnsupportedEndpointError) else None
- )
- deployment_id: Final = configured_deployment_id or model_info.get("id")
- if isinstance(deployment_id, str) and deployment_id:
- exception.retry_skip_deployment_id = deployment_id # pyright: ignore[reportAttributeAccessIssue] # dynamic stamp, read by _deployment_ids_to_skip_on_retry
-
- def _update_kwargs_with_default_litellm_params(
- self, kwargs: dict, metadata_variable_name: str | None = "metadata"
- ) -> None:
- """
- Adds default litellm params to kwargs, if set.
-
- Handles inserting this as either "metadata" or "litellm_metadata" depending on the metadata_variable_name
- """
- # 1) copy your defaults and pull out metadata
- defaults: Final = dict(without_retrieve_stream_defaults(self._core_default_litellm_params, kwargs))
- metadata_defaults: Final = defaults.pop("metadata", {}) or {}
-
- # 2) add any non-metadata defaults that aren't already in kwargs
- for key, value in defaults.items():
- if value is None:
- continue
- kwargs.setdefault(key, value)
-
- # 3) merge in metadata, this handles inserting this as either "metadata" or "litellm_metadata"
- kwargs.setdefault(metadata_variable_name, {}).update(metadata_defaults)
-
- def _handle_clientside_credential(
- self, deployment: dict, kwargs: dict, function_name: str | None = None
- ) -> Deployment:
- """
- Build a per-request Deployment carrying the caller-supplied api_key/api_base,
- with its own stable id for cooldown, logging, and cost-map identity.
-
- This deployment is deliberately never registered with the router (no
- upsert_deployment/add_deployment call): doing so used to add it to
- self._core_model_list under the shared model_name, which made a request-scoped,
- caller-supplied provider credential a permanent, load-balanced deployment
- that every other caller of that model group could be routed onto. Its
- pricing is still registered directly, so a custom price configured on the
- underlying deployment still applies to this call.
- """
- model_info: Final = deployment.get("model_info", {}).copy()
- litellm_params: Final = deployment["litellm_params"].copy()
- dynamic_litellm_params: Final = get_dynamic_litellm_params(litellm_params=litellm_params, request_kwargs=kwargs)
- # Use deployment model_name as model_group for generating model_id
- metadata_variable_name: Final = get_router_metadata_variable_name(
- function_name=function_name,
- )
- model_group: Final = kwargs.get(metadata_variable_name, {}).get("model_group")
- _model_id: Final = self._core_generate_model_id(model_group=model_group, litellm_params=dynamic_litellm_params)
- original_model_id: Final = model_info.get("id")
- model_info["id"] = _model_id
- model_info["original_model_id"] = original_model_id
- deployment_pydantic_obj: Final = Deployment(
- model_name=model_group,
- litellm_params=LiteLLM_Params.model_validate(dynamic_litellm_params),
- model_info=model_info,
- )
- Router._register_deployment_pricing(deployment=deployment_pydantic_obj)
- return deployment_pydantic_obj
-
- @staticmethod
- def _merge_tools_from_deployment(
- deployment: dict, kwargs: dict, defaults: Mapping[str, object] = _NO_DEFAULT_PARAMS
- ) -> None:
- """
- Merge tools from deployment litellm_params with request kwargs.
- When both have tools, concatenate them (deployment tools first, then request tools).
- tool_choice: use request value if provided, else deployment's.
- """
- dep_params_raw: Final = deployment.get("litellm_params", {}) or {}
- if isinstance(dep_params_raw, dict):
- dep_params = dep_params_raw
- else:
- dep_params = dep_params_raw.model_dump(exclude_none=True)
- if _drops_malformed_request_tools(dep_params, defaults, kwargs):
- for name in TOOL_FIELDS:
- kwargs.pop(name, None)
- merged_tools: Final = _merged_tools(dep_params.get("tools") or [], kwargs.get("tools") or [])
- if merged_tools is not None:
- kwargs["tools"] = merged_tools
- if "tool_choice" not in kwargs and dep_params.get("tool_choice") is not None:
- kwargs["tool_choice"] = dep_params["tool_choice"]
-
- def _update_kwargs_with_deployment(
- self,
- deployment: dict,
- kwargs: dict,
- function_name: str | None = None,
- splits_stream_timeout: bool = False,
- opens_responses_socket: bool = False,
- ) -> None:
- """
- 5 jobs:
- - Adds selected deployment, model_info and api_base to kwargs["metadata"] (used for logging)
- - Adds default litellm params to kwargs, if set.
- - Merges tools from deployment with request (proxy-configured tools + request tools).
- - On a fallback hop, drops the encrypted reasoning this deployment cannot decrypt, keeping its summary.
- - On a Responses continuation, sends the partial response as prefill where this deployment takes one.
- """
- for key in self._forwarded_alias_marker_keys_the_deployment_sets(
- deployment=deployment, forwarded_keys=kwargs.pop(_ALIAS_MARKER_FORWARDED_PARAMS_KWARG, ())
- ):
- kwargs.pop(key, None)
- kwargs.pop("daily_reset_time", None) # pyright: ignore[reportUnknownMemberType] # kwargs is an untyped dict
- defaults: Final = _LITELLM_PARAMS_ADAPTER.validate_python(self._core_default_litellm_params)
- self._merge_tools_from_deployment(deployment=deployment, kwargs=kwargs, defaults=defaults)
-
- model_info = deployment.get("model_info", {}).copy()
- deployment_litellm_model_name = deployment["litellm_params"]["model"]
- deployment_api_base = deployment["litellm_params"].get("api_base")
- deployment_model_name: Final = deployment["model_name"]
- from litellm.litellm_core_utils.get_provider_specific_headers import ProviderSpecificHeaderUtils
-
- request_context: Final = cast( # cast-ok: the router's legacy request dict has string keys
- Mapping[str, object], kwargs
- )
- raw_deployment_params: Final[object] = deployment["litellm_params"]
- deployment_context: Final = _LITELLM_PARAMS_ADAPTER.validate_python(
- raw_deployment_params.model_dump(exclude_none=True)
- if isinstance(raw_deployment_params, LiteLLM_Params)
- else raw_deployment_params
- )
- _drop_malformed_request_params_the_deployment_sets(
- kwargs, # pyright: ignore[reportUnknownArgumentType] # kwargs is the router's untyped request dict
- deployment_context,
- defaults,
- )
- declared_provider: Final = deployment_context.get("custom_llm_provider")
- declared_model: Final = deployment_context.get("model")
- deployment_provider: Final = (
- declared_provider
- if isinstance(declared_provider, str)
- else inferred_provider(declared_model if isinstance(declared_model, str) else None)
- )
- forwarded_authorization: Final = (
- ProviderSpecificHeaderUtils.forwarded_authorization(
- request_context.get("provider_specific_header"), deployment_provider
- )
- if deployment_provider in _FORWARDED_CREDENTIAL_ISOLATION_PROVIDERS
- else None
- )
- if is_clientside_credential(request_kwargs=kwargs) or forwarded_authorization is not None:
- deployment_pydantic_obj: Final = self._handle_clientside_credential(
- deployment=deployment,
- kwargs={
- **kwargs,
- **({"api_key": forwarded_authorization} if forwarded_authorization is not None else {}),
- },
- function_name=function_name,
- )
- model_info = deployment_pydantic_obj.model_info.model_dump()
- deployment_litellm_model_name = deployment_pydantic_obj.litellm_params.model
- deployment_api_base = deployment_pydantic_obj.litellm_params.api_base
-
- metadata_variable_name: Final = get_router_metadata_variable_name(
- function_name=function_name,
- )
- deployment_that_just_failed: Final = _deployment_that_just_failed(
- _FALLBACK_HOP_ADAPTER.validate_python(kwargs).get(metadata_variable_name)
- )
- routing: Final = deployment.get(KUBERNETES_POD_ROUTING_KEY)
-
- kwargs.setdefault(metadata_variable_name, {}).update(
- {
- "deployment": deployment_litellm_model_name,
- "model_info": model_info,
- "api_base": deployment_api_base,
- "deployment_model_name": deployment_model_name,
- KUBERNETES_POD_ROUTING_KEY: routing,
- }
- )
- other_bucket: Final = "metadata" if metadata_variable_name == "litellm_metadata" else "litellm_metadata"
- other_bucket_metadata: Final = kwargs.get(other_bucket)
- if isinstance(other_bucket_metadata, Mapping) and KUBERNETES_POD_ROUTING_KEY in other_bucket_metadata:
- kwargs[other_bucket] = { # rebind-ok: this method updates the router's request kwargs in place
- **other_bucket_metadata,
- KUBERNETES_POD_ROUTING_KEY: routing,
- }
-
- # A retry/fallback reuses this same kwargs dict for the next deployment.
- # Refund and clear any reservation the previous deployment attempt left
- # here before it's wiped below, instead of relying on that attempt's
- # (possibly still-pending) failure event to do it.
- refund_stale_reservation_before_retry(self._core_cache, kwargs)
- set_io_token_rate_limit_request_kwargs(
- kwargs,
- store_in_context=deployment_has_io_token_limits(deployment),
- opens_responses_socket=opens_responses_socket,
- metadata_channel=metadata_variable_name,
- )
-
- kwargs[metadata_variable_name].setdefault(
- ROUTING_REQUEST_TAGS_METADATA_KEY,
- tuple(get_tags_from_request_kwargs(kwargs, metadata_variable_name=metadata_variable_name)),
- )
-
- ## DEPLOYMENT-LEVEL TAGS
- deployment_tags: Final = deployment.get("litellm_params", {}).get("tags")
- merged_tags: Final = tags_with_serving_deployment_tags(kwargs[metadata_variable_name], deployment_tags)
- if merged_tags is not None:
- kwargs[metadata_variable_name]["tags"] = list(merged_tags)
-
- baggage_stamp: Final = baggage_stamp_for_serving_deployment(kwargs[metadata_variable_name], deployment_tags)
- if baggage_stamp is not None:
- kwargs[metadata_variable_name][BAGGAGE_MAPPED_TAGS_METADATA_KEY] = baggage_stamp
-
- ## CREDENTIAL NAME AS TAG
- credential_name: Final = deployment.get("litellm_params", {}).get("litellm_credential_name")
- if credential_name:
- credential_tag: Final = f"Credential: {credential_name}"
- existing_tags = kwargs[metadata_variable_name].get("tags") or []
- if credential_tag not in existing_tags:
- existing_tags.append(credential_tag)
- kwargs[metadata_variable_name]["tags"] = existing_tags
-
- from litellm.llms.github_copilot.per_user_auth import (
- github_copilot_per_user_credential_name,
- )
-
- configured_per_user_name: Final = github_copilot_per_user_credential_name(
- cast( # cast-ok: deployment litellm_params is a str-keyed dict
- "Mapping[str, object]", deployment.get("litellm_params")
- )
- )
- caller_credential_name: Final[object] = cast( # cast-ok: kwargs is the untyped request dict
- "object", kwargs.get("litellm_credential_name")
- )
- if (
- configured_per_user_name is not None
- and isinstance(caller_credential_name, str)
- and caller_credential_name != configured_per_user_name
- ):
- raise litellm.BadRequestError(
- message="litellm_credential_name cannot be overridden on a deployment that uses per-user GitHub OAuth",
- model=deployment_model_name,
- llm_provider="",
- )
-
- kwargs["model_info"] = model_info
-
- if function_name == "_ageneric_api_call_with_fallbacks":
- from litellm.passthrough.timeout_utils import (
- resolve_llm_passthrough_timeout,
- )
-
- _router_timeout: Final = (
- self.request_timeout
- if self.request_timeout is not None
- else float(self._explicit_timeout)
- if isinstance(self._explicit_timeout, (int, float))
- else None
- )
- _router_stream_timeout: Final = (
- self.stream_timeout
- if self.stream_timeout is not None
- else self.request_timeout
- if self.request_timeout is not None
- else self._core_default_litellm_params.get("stream_timeout")
- )
- passthrough_timeout: Final = partial(
- resolve_llm_passthrough_timeout,
- litellm_params=deployment["litellm_params"],
- router_timeout=_router_timeout,
- router_stream_timeout=_router_stream_timeout,
- )
- timeout_request: Final = {**kwargs, "stream": True} if requests_streamed_retrieval(kwargs) else kwargs
- kwargs.update(
- CompletionTimeout.stream_request_kwargs(
- passthrough_timeout(kwargs={**kwargs, "stream": False}), passthrough_timeout(kwargs=timeout_request)
- )
- if splits_stream_timeout and timeout_request.get("stream")
- else {"timeout": passthrough_timeout(kwargs=timeout_request)}
- )
- elif splits_stream_timeout:
- request_kwargs: Final = _REQUEST_KWARGS_ADAPTER.validate_python(kwargs)
- deployment_params: Final = _DEPLOYMENT_PARAMS_ADAPTER.validate_python(deployment["litellm_params"])
- kwargs.update(
- CompletionTimeout.stream_request_kwargs(
- self._get_non_stream_timeout(kwargs=request_kwargs, data=deployment_params),
- self._get_stream_timeout(kwargs=request_kwargs, data=deployment_params)
- if request_kwargs.get("stream")
- else None,
- )
- )
- else:
- kwargs["timeout"] = self._get_timeout(
- kwargs=_REQUEST_KWARGS_ADAPTER.validate_python(kwargs),
- data=_DEPLOYMENT_PARAMS_ADAPTER.validate_python(deployment["litellm_params"]),
- )
-
- deployment_litellm_params: Final = _DEPLOYMENT_PARAMS_ADAPTER.validate_python(deployment["litellm_params"])
- if router_config_requests_stream_usage(
- _REQUEST_KWARGS_ADAPTER.validate_python(kwargs),
- (
- _LITELLM_PARAMS_ADAPTER.validate_python(
- deployment_litellm_params
- if isinstance(deployment_litellm_params, Mapping)
- else deployment_litellm_params.model_dump(exclude_none=True)
- ),
- defaults,
- ),
- ):
- kwargs[STREAM_USAGE_INJECTED_FIELD] = True
- self._update_kwargs_with_default_litellm_params(kwargs=kwargs, metadata_variable_name=metadata_variable_name)
- pinned_model_id: Final = deployment_model_id_pin(deployment_context)
- if pinned_model_id is not None:
- kwargs["model_id"] = pinned_model_id # rebind-ok: this method's contract is filling the caller's kwargs
- hop_kwargs: Final = _FALLBACK_HOP_ADAPTER.validate_python(kwargs)
- if _is_fallback_hop(hop_kwargs):
- EncryptedContentAffinityCheck.strip_reasoning_the_targets_cannot_decrypt(
- self._core_ctx,
- hop_kwargs.get("input"),
- hop_kwargs.get("messages"),
- (_FALLBACK_HOP_ADAPTER.validate_python(deployment),),
- unmarked_origin=deployment_that_just_failed,
- )
- if RESPONSES_CONTINUATION_KWARG in hop_kwargs:
- del kwargs[RESPONSES_CONTINUATION_KWARG] # rebind-ok: this method prepares the call's kwargs in place
- kwargs["input"] = responses_continuation_input_for( # rebind-ok: the deployment's continuation input
- hop_kwargs, _FALLBACK_HOP_ADAPTER.validate_python(deployment)
- )
-
- def _get_async_openai_model_client(self, deployment: dict, kwargs: dict):
- """
- Helper to get AsyncOpenAI or AsyncAzureOpenAI client that was created for the deployment
-
- The same OpenAI client is re-used to optimize latency / performance in production
-
- If dynamic api key is provided:
- Do not re-use the client. Pass model_client=None. The OpenAI/ AzureOpenAI client will be recreated in the handler for the llm provider
- """
- potential_model_client: Final = self._get_client(deployment=deployment, kwargs=kwargs, client_type="async")
-
- # check if provided keys == client keys #
- dynamic_api_key: Final = kwargs.get("api_key", None)
- if (
- dynamic_api_key is not None
- and potential_model_client is not None
- and dynamic_api_key != potential_model_client.api_key
- ):
- model_client = None
- else:
- model_client = potential_model_client
-
- return model_client
-
- def _get_stream_timeout(
- self, kwargs: Mapping[str, object], data: Mapping[str, object] | GenericLiteLLMParams
- ) -> object:
- """Helper to get stream timeout from kwargs or deployment params"""
- return (
- kwargs.get("stream_timeout", None) # the params dynamically set by user
- or get_service_tier_timeout(kwargs.get("service_tier"), data, "stream_timeout_by_service_tier")
- or data.get("stream_timeout", None) # timeout set on litellm_params for this deployment
- or self.stream_timeout # timeout set on router
- or self.request_timeout # litellm_settings.request_timeout (per-attempt)
- or self._core_default_litellm_params.get("stream_timeout", None)
- )
-
- def _get_non_stream_timeout(
- self, kwargs: Mapping[str, object], data: Mapping[str, object] | GenericLiteLLMParams
- ) -> object:
- """Helper to get non-stream timeout from kwargs or deployment params"""
- return (
- kwargs.get("timeout", None) # the params dynamically set by user
- or kwargs.get("request_timeout", None) # the params dynamically set by user
- or get_service_tier_timeout(kwargs.get("service_tier"), data, "timeout_by_service_tier")
- or data.get("timeout", None) # timeout set on litellm_params for this deployment
- or data.get("request_timeout", None) # timeout set on litellm_params for this deployment
- or self.request_timeout # litellm_settings.request_timeout (per-attempt)
- or self.timeout # timeout set on router (router_settings.timeout)
- or self._core_default_litellm_params.get("timeout", None)
- )
-
- def _get_timeout(self, kwargs: Mapping[str, object], data: Mapping[str, object] | GenericLiteLLMParams) -> object:
- """Helper to get timeout from kwargs or deployment params"""
- stream_timeout: Final = self._get_stream_timeout(kwargs=kwargs, data=data) if kwargs.get("stream") else None
- return stream_timeout if stream_timeout is not None else self._get_non_stream_timeout(kwargs=kwargs, data=data)
-
- async def abatch_completion(
- self,
- models: list[str],
- messages: list[dict[str, str]] | list[list[dict[str, str]]],
- **kwargs,
- ):
- """
- Async Batch Completion. Used for 2 scenarios:
- 1. Batch Process 1 request to N models on litellm.Router. Pass messages as List[Dict[str, str]] to use this
- 2. Batch Process N requests to M models on litellm.Router. Pass messages as List[List[Dict[str, str]]] to use this
-
- Example Request for 1 request to N models:
- ```
- response = await router.abatch_completion(
- models=["gpt-3.5-turbo", "groq-llama"],
- messages=[
- {"role": "user", "content": "is litellm becoming a better product ?"}
- ],
- max_tokens=15,
- )
- ```
-
-
- Example Request for N requests to M models:
- ```
- response = await router.abatch_completion(
- models=["gpt-3.5-turbo", "groq-llama"],
- messages=[
- [{"role": "user", "content": "is litellm becoming a better product ?"}],
- [{"role": "user", "content": "who is this"}],
- ],
- )
- ```
- """
- ############## Helpers for async completion ##################
-
- async def _async_completion_no_exceptions(model: str, messages: list[AllMessageValues], **kwargs):
- """
- Wrapper around self.async_completion that catches exceptions and returns them as a result
- """
- try:
- return await self.acompletion(model=model, messages=messages, **kwargs)
- except Exception as e:
- return e
-
- async def _async_completion_no_exceptions_return_idx(
- model: str,
- messages: list[AllMessageValues],
- idx: int, # index of message this response corresponds to
- **kwargs,
- ):
- """
- Wrapper around self.async_completion that catches exceptions and returns them as a result
- """
- try:
- return (
- await self.acompletion(model=model, messages=messages, **kwargs),
- idx,
- )
- except Exception as e:
- return e, idx
-
- ############## Helpers for async completion ##################
-
- if isinstance(messages, list) and all(isinstance(m, dict) for m in messages):
- _tasks = []
- for model in models:
- # add each task but if the task fails
- _tasks.append(_async_completion_no_exceptions(model=model, messages=messages, **kwargs))
- response = await asyncio.gather(*_tasks)
- return response
- elif isinstance(messages, list) and all(isinstance(m, list) for m in messages):
- _tasks = []
- for idx, message in enumerate(messages):
- for model in models:
- # Request Number X, Model Number Y
- _tasks.append(
- _async_completion_no_exceptions_return_idx(
- model=model,
- idx=idx,
- messages=message,
- **kwargs,
- )
- )
- responses: Final = await asyncio.gather(*_tasks)
- final_responses: Final[list[list[object]]] = [[] for _ in range(len(messages))]
- for response in responses:
- if isinstance(response, tuple):
- final_responses[response[1]].append(response[0])
- else:
- final_responses[0].append(response)
- return final_responses
-
- async def abatch_completion_one_model_multiple_requests(
- self, model: str, messages: list[list[AllMessageValues]], **kwargs
- ):
- """
- Async Batch Completion - Batch Process multiple Messages to one model_group on litellm.Router
-
- Use this for sending multiple requests to 1 model
-
- Args:
- model (List[str]): model group
- messages (List[List[Dict[str, str]]]): list of messages. Each element in the list is one request
- **kwargs: additional kwargs
- Usage:
- response = await self.abatch_completion_one_model_multiple_requests(
- model="gpt-3.5-turbo",
- messages=[
- [{"role": "user", "content": "hello"}, {"role": "user", "content": "tell me something funny"}],
- [{"role": "user", "content": "hello good mornign"}],
- ]
- )
- """
-
- async def _async_completion_no_exceptions(model: str, messages: list[AllMessageValues], **kwargs):
- """
- Wrapper around self.async_completion that catches exceptions and returns them as a result
- """
- try:
- return await self.acompletion(model=model, messages=messages, **kwargs)
- except Exception as e:
- return e
-
- _tasks: Final = []
- for message_request in messages:
- # add each task but if the task fails
- _tasks.append(_async_completion_no_exceptions(model=model, messages=message_request, **kwargs))
-
- response: Final = await asyncio.gather(*_tasks)
- return response
-
- # fmt: off
-
- @overload
- async def abatch_completion_fastest_response(
- self, model: str, messages: list[dict[str, str]], stream: Literal[True], **kwargs
- ) -> CustomStreamWrapper:
- ...
-
-
-
- @overload
- async def abatch_completion_fastest_response(
- self, model: str, messages: list[dict[str, str]], stream: Literal[False] = False, **kwargs
- ) -> ModelResponse:
- ...
-
- # fmt: on
-
- async def abatch_completion_fastest_response(
- self,
- model: str,
- messages: list[dict[str, str]],
- stream: bool = False,
- **kwargs,
- ):
- """
- model - List of comma-separated model names. E.g. model="gpt-4, gpt-3.5-turbo"
-
- Returns fastest response from list of model names. OpenAI-compatible endpoint.
- """
- models: Final = [m.strip() for m in model.split(",")]
-
- async def _async_completion_no_exceptions(
- model_name: str, messages: list[dict[str, str]], stream: bool, **kwargs: object
- ) -> ModelResponse | CustomStreamWrapper | Exception:
- """
- Wrapper around self.acompletion that catches exceptions and returns them as a result
- """
- try:
- result = await self.acompletion(model=model_name, messages=messages, stream=stream, **kwargs)
- return result
- except asyncio.CancelledError:
- verbose_router_logger.debug("Received 'task.cancel'. Cancelling call w/ model=%s.", model_name)
- raise
- except Exception as e:
- return e
-
- pending_tasks = []
-
- async def check_response(task: asyncio.Task):
- nonlocal pending_tasks
- try:
- result: Final = await task
- if isinstance(result, (ModelResponse, CustomStreamWrapper)):
- verbose_router_logger.debug("Received successful response. Cancelling other LLM API calls.")
- # If a desired response is received, cancel all other pending tasks
- for t in pending_tasks:
- t.cancel()
- return result
- except Exception:
- # Ignore exceptions, let the loop handle them
- pass
- finally:
- # Remove the task from pending tasks if it finishes
- try:
- pending_tasks.remove(task)
- except KeyError:
- pass
-
- for model_name in models:
- task = asyncio.create_task(
- _async_completion_no_exceptions(model_name=model_name, messages=messages, stream=stream, **kwargs)
- )
- pending_tasks.append(task)
-
- # Await the first task to complete successfully
- while pending_tasks:
- done, pending_tasks = await asyncio.wait(pending_tasks, return_when=asyncio.FIRST_COMPLETED)
- for completed_task in done:
- result = await check_response(completed_task)
-
- if result is not None:
- # Return the first successful result
- if (result_hidden_params := get_hidden_params(result)) is not None:
- result_hidden_params["fastest_response_batch_completion"] = True
- return result
-
- # If we exit the loop without returning, all tasks failed
- raise Exception("All tasks failed")
-
- ### SCHEDULER ###
-
- # fmt: off
-
- @overload
- async def schedule_acompletion(
- self,
- model: str,
- messages: list[AllMessageValues],
- priority: int | None = None,
- stream: Literal[False] = False,
- **kwargs,
- ) -> ModelResponse:
- ...
-
- @overload
- async def schedule_acompletion(
- self, model: str, messages: list[AllMessageValues], priority: int | None, stream: Literal[True], **kwargs
- ) -> CustomStreamWrapper:
- ...
-
- # fmt: on
-
- async def schedule_acompletion(
- self,
- model: str,
- messages: list[AllMessageValues],
- priority: int | None = None,
- stream=False,
- **kwargs,
- ):
- queued_priority, _ = self._core_split_request_priority(model=model, kwargs={**kwargs, "priority": priority})
- if queued_priority is None:
- raise priority_error(model=model, message=unqueueable_priority_message(priority))
- return await self.acompletion(model=model, messages=messages, stream=stream, priority=queued_priority, **kwargs)
-
- def _core_split_request_priority(
- self, model: str, kwargs: Mapping[str, object]
- ) -> tuple[int | None, Mapping[str, object]]:
- request_priority: Final = resolve_request_priority(
- requested=kwargs.get("priority"),
- default_priority=self.default_priority,
- drops_params=lambda: self._drops_params(model=model, kwargs=kwargs),
- )
- if isinstance(request_priority, InvalidPriority):
- raise priority_error(model=model, message=request_priority.message)
- return request_priority, {key: value for key, value in kwargs.items() if key != "priority"}
-
- def priority_header_update(
- self, model: str, kwargs: Mapping[str, object], header: str | None
- ) -> Mapping[str, int | str]:
- return priority_header_fields(header, drops_params=lambda: self._drops_params(model=model, kwargs=kwargs))
-
- def _drops_params(self, model: str, kwargs: Mapping[str, object]) -> bool:
- return request_drops_params(
- kwargs, self._core_default_litellm_params, self._request_deployment_params(model=model, kwargs=kwargs)
- )
-
- async def _schedule_factory(
- self,
- model: str,
- priority: int,
- original_function: Callable[..., Awaitable[object]],
- args: tuple[object, ...],
- kwargs: Mapping[str, object],
- ):
- await self._wait_for_scheduler_turn(
- model=model,
- priority=priority,
- parent_otel_span=get_parent_otel_span_from_kwargs(kwargs),
- request_kwargs=kwargs,
- )
- try:
- _response: Final = await original_function(*args, **kwargs)
- response_hidden_params: Final = get_hidden_params(_response)
- if response_hidden_params is not None:
- additional_headers: Final = cast( # cast-ok: router headers are stored as a mutable mapping
- dict[str, object], response_hidden_params.setdefault("additional_headers", {})
- )
- additional_headers.update({"x-litellm-request-prioritization-used": True})
- return _response
- except Exception as e:
- setattr(e, "priority", priority)
- raise e
-
- async def _wait_for_scheduler_turn(
- self, model: str, priority: int, parent_otel_span: Span | None, request_kwargs: Mapping[str, object]
- ) -> None:
- pinned: Final = self._pinned_deployment(model)
-
- async def healthy_deployments() -> Sequence[object]:
- if pinned is not None:
- return (pinned,)
- deployments, _ = await self._async_get_healthy_deployments(
- model=model,
- parent_otel_span=parent_otel_span,
- request_kwargs=share_holder_request_kwargs(request_kwargs, self._ptu_share_teams()),
- )
- return deployments
-
- await self.scheduler.wait_for_turn(
- request=FlowItem(priority=priority, request_id=str(uuid.uuid4()), model_name=model),
- timeout=self.timeout,
- get_healthy_deployments=healthy_deployments,
- )
-
- def _is_prompt_management_model(self, model: str) -> bool:
- model_list: Final = self._core_get_model_list(model_name=model)
- if model_list is None or len(model_list) != 1:
- return False
-
- litellm_model: Final = model_list[0]["litellm_params"].get("model", None)
- if litellm_model is None or "/" not in litellm_model:
- return False
-
- split_litellm_model: Final = litellm_model.split("/")[0]
- return split_litellm_model in litellm._known_custom_logger_compatible_callbacks
-
- def _pinned_deployment(self, model: str) -> Deployment | None:
- return self._core_get_deployment(model_id=model) if model not in self._core_model_names else None
-
- def _request_deployment_params(self, model: str, kwargs: Mapping[str, object]) -> tuple[Mapping[str, object], ...]:
- deployment: Final = self._pinned_deployment(model)
- if deployment is not None:
- return (deployment.litellm_params.model_dump(exclude_none=True),)
- candidates: Final = (
- self._get_deployment_by_litellm_model(model=model)
- if kwargs.get("specific_deployment") is True
- else self._core_get_model_list(model_name=model, team_id=get_request_team_id(kwargs)) or ()
- )
- return tuple(candidate["litellm_params"] for candidate in candidates)
-
- def _prompt_management_acompletion(
- self,
- model: str,
- messages: list[AllMessageValues], # mutable-ok: _prompt_management_factory takes the caller's list
- ) -> Callable[..., Awaitable[object]]:
- async def acompletion(**kwargs: object) -> object: # kwargs-ok: called with the request's own keyword payload
- return await self._prompt_management_factory(model=model, messages=messages, kwargs=dict(kwargs))
-
- return acompletion
-
- async def _prompt_management_factory(
- self,
- model: str,
- messages: list[AllMessageValues],
- kwargs: dict[str, Any],
- ):
- litellm_logging_object = kwargs.get("litellm_logging_obj", None)
- if litellm_logging_object is None:
- litellm_logging_object, kwargs = function_setup(
- **{
- "original_function": "acompletion",
- "rules_obj": Rules(),
- "start_time": get_utc_datetime(),
- **kwargs,
- }
- )
- litellm_logging_object = cast(LiteLLMLogging, litellm_logging_object)
- caller_timeout: Final = kwargs.get("timeout") or kwargs.get("request_timeout")
- caller_stream_timeout: Final = kwargs.get("stream_timeout")
- specific_deployment: Final = kwargs.pop("specific_deployment", None)
- prompt_management_deployment: Final = await self._core_async_get_available_deployment(
- model=model,
- messages=cast(list[dict[str, str]], messages), # cast-ok: selection reads messages structurally
- specific_deployment=specific_deployment,
- request_kwargs=kwargs,
- )
-
- self._update_kwargs_with_deployment(
- deployment=prompt_management_deployment, kwargs=kwargs, splits_stream_timeout=True
- )
- data: Final = prompt_management_deployment["litellm_params"].copy()
-
- litellm_model: Final = data.get("model", None)
-
- # litellm_agent/ prefix only strips the model name, no prompt_id needed
- is_litellm_agent_model: Final = isinstance(litellm_model, str) and litellm_model.startswith("litellm_agent/")
-
- prompt_id = kwargs.get("prompt_id") or prompt_management_deployment["litellm_params"].get("prompt_id", None)
- prompt_variables: Final = kwargs.get("prompt_variables") or prompt_management_deployment["litellm_params"].get(
- "prompt_variables", None
- )
- prompt_label: Final = kwargs.get("prompt_label", None) or prompt_management_deployment["litellm_params"].get(
- "prompt_label", None
- )
-
- if not is_litellm_agent_model and (prompt_id is None or not isinstance(prompt_id, str)):
- raise ValueError(f"Prompt ID is not set or not a string. Got={prompt_id}, type={type(prompt_id)}")
- if prompt_variables is not None and not isinstance(prompt_variables, dict):
- raise ValueError(
- f"Prompt variables is set but not a dictionary. Got={prompt_variables}, type={type(prompt_variables)}"
- )
-
- (
- model,
- messages,
- optional_params,
- ) = litellm_logging_object.get_chat_completion_prompt(
- model=litellm_model,
- messages=messages,
- non_default_params=get_non_default_completion_params(kwargs=kwargs),
- prompt_id=prompt_id,
- prompt_variables=prompt_variables,
- prompt_label=prompt_label,
- request_kwargs=kwargs,
- injected_for_every_deployment=True,
- )
-
- # Filter out prompt management specific parameters from data before merging
- prompt_management_params: Final = {
- "bitbucket_config",
- "dotprompt_config",
- "prompt_id",
- "prompt_variables",
- "prompt_label",
- "prompt_version",
- }
- filtered_data: Final = {k: v for k, v in data.items() if k not in prompt_management_params}
-
- kwargs = {**filtered_data, **kwargs, **optional_params}
- kwargs["model"] = model
- kwargs["messages"] = messages
- kwargs["litellm_logging_obj"] = litellm_logging_object
- kwargs["prompt_id"] = prompt_id
- kwargs["prompt_variables"] = prompt_variables
- kwargs["prompt_label"] = prompt_label
-
- _model_list: Final = self._core_get_model_list(model_name=model)
- if _model_list is None or len(_model_list) == 0: # if direct call to model
- kwargs.pop("original_function")
- return await litellm.acompletion(**kwargs)
-
- target_group_timeouts: Final = {
- key: value
- for key, value in (
- ("timeout", caller_timeout if caller_timeout is not None else filtered_data.get("timeout")),
- (
- "stream_timeout",
- caller_stream_timeout if caller_stream_timeout is not None else filtered_data.get("stream_timeout"),
- ),
- )
- if value is not None
- }
- return await self.async_function_with_fallbacks(
- **{key: value for key, value in kwargs.items() if key not in ("timeout", "stream_timeout")},
- **target_group_timeouts,
- )
-
- def image_generation(self, prompt: str, model: str, **kwargs):
- try:
- kwargs["model"] = model
- kwargs["prompt"] = prompt
- kwargs["original_function"] = self._image_generation
- kwargs.setdefault("metadata", {}).update({"model_group": model})
- response: Final = self.function_with_fallbacks(**kwargs)
-
- return response
- except Exception as e:
- raise e
-
- def _image_generation(self, prompt: str, model: str, **kwargs):
- model_name: Final = ""
- try:
- verbose_router_logger.debug("Inside _image_generation()- model: %s", model)
- deployment: Final = self.get_available_deployment(
- model=model,
- messages=[{"role": "user", "content": "prompt"}],
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
- self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
- data: Final = deployment["litellm_params"].copy()
-
- model_client: Final = self._get_async_openai_model_client(
- deployment=deployment,
- kwargs=kwargs,
- )
-
- self.total_calls[model_name] += 1
-
- ### DEPLOYMENT-SPECIFIC PRE-CALL CHECKS ### (e.g. update rpm pre-call. Raise error, if deployment over limit)
- self.routing_strategy_pre_call_checks(deployment=deployment)
-
- response: Final = litellm.image_generation(
- **{
- **data,
- "prompt": prompt,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs,
- }
- )
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.image_generation(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
- return response
- except Exception as e:
- verbose_router_logger.info("litellm.image_generation(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
- if model_name is not None:
- self.fail_calls[model_name] += 1
- self._stamp_retry_skip_deployment_id(e, kwargs)
- raise e
-
- async def aimage_generation(self, prompt: str, model: str, **kwargs):
- try:
- kwargs["model"] = model
- kwargs["prompt"] = prompt
- kwargs["original_function"] = self._aimage_generation
- self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
- response: Final = await self.async_function_with_fallbacks(**kwargs)
-
- return response
- except Exception as e:
- asyncio.create_task(
- send_llm_exception_alert(
- ctx=self._core_ctx,
- request_kwargs=kwargs,
- error_traceback_str=traceback.format_exc(),
- original_exception=e,
- )
- )
- raise e
-
- async def _aimage_generation(self, prompt: str, model: str, **kwargs):
- model_name = model
- start_time: Final = datetime.now() # noqa: DTZ005 # SDK logging uses naive local timestamps
- try:
- verbose_router_logger.debug("Inside _image_generation()- model: %s", model)
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- try:
- deployment: Final = await self._core_async_get_available_deployment(
- model=model,
- messages=[{"role": "user", "content": "prompt"}],
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
- except Exception as selection_error:
- # Admitted attempts keep SDK-owned logging, including deployment callbacks and retry state
- request_kwargs: Final = cast( # cast-ok: Python kwargs have string keys and opaque values
- Mapping[str, object], kwargs
- )
- if request_kwargs.get("litellm_logging_obj") is None:
- refusal_kwargs: Final = {**request_kwargs, "model": model, "prompt": prompt}
- refusal_kwargs.setdefault("litellm_call_id", str(uuid.uuid4()))
- logging_obj, _ = function_setup(
- "aimage_generation",
- Rules(),
- start_time,
- **refusal_kwargs, # pyright: ignore[reportArgumentType] # preserve opaque SDK options, including is_async_call
- )
- try:
- asyncio.create_task(
- logging_obj.dispatch_failure_handlers(
- exception=selection_error,
- traceback_exception=traceback.format_exc(),
- prefer_async_handlers=True,
- )
- )
- finally:
- _restore_correlation_context_if_supported(logging_obj)
- raise
- self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
-
- data: Final = deployment["litellm_params"].copy()
- model_name = data["model"]
-
- model_client: Final = self._get_async_openai_model_client(
- deployment=deployment,
- kwargs=kwargs,
- )
-
- self.total_calls[model_name] += 1
- async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
- response = await litellm.aimage_generation(
- **{
- **data,
- "prompt": prompt,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs,
- }
- )
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.aimage_generation(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
- return response
- except Exception as e:
- verbose_router_logger.info("litellm.aimage_generation(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
- if model_name is not None:
- self.fail_calls[model_name] += 1
- self._stamp_retry_skip_deployment_id(e, kwargs)
- raise e
-
- async def atranscription(self, file: FileTypes, model: str, **kwargs):
- """
- Example Usage:
-
- ```
- from litellm import Router
- client = Router(model_list = [
- {
- "model_name": "whisper",
- "litellm_params": {
- "model": "whisper-1",
- },
- },
- ])
-
- audio_file = open("speech.mp3", "rb")
- transcript = await client.atranscription(
- model="whisper",
- file=audio_file
- )
-
- ```
- """
- try:
- kwargs["model"] = model
- kwargs["file"] = file
- kwargs["original_function"] = self._atranscription
- self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
- response: Final = await self.async_function_with_fallbacks(**kwargs)
-
- return response
- except Exception as e:
- asyncio.create_task(
- send_llm_exception_alert(
- ctx=self._core_ctx,
- request_kwargs=kwargs,
- error_traceback_str=traceback.format_exc(),
- original_exception=e,
- )
- )
- raise e
-
- async def _atranscription(self, file: FileTypes, model: str, **kwargs):
- model_name: Final = model
- try:
- verbose_router_logger.debug("Inside _atranscription()- model: %s", model)
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- deployment: Final = await self._core_async_get_available_deployment(
- model=model,
- messages=[{"role": "user", "content": "prompt"}],
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
-
- self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
- data: Final = deployment["litellm_params"].copy()
- model_client: Final = self._get_async_openai_model_client(
- deployment=deployment,
- kwargs=kwargs,
- )
-
- self.total_calls[model_name] += 1
- async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
- response = await litellm.atranscription(
- **{
- **data,
- "file": file,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs,
- }
- )
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.atranscription(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
- return response
- except Exception as e:
- verbose_router_logger.info("litellm.atranscription(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
- if model_name is not None:
- self.fail_calls[model_name] += 1
- self._stamp_retry_skip_deployment_id(e, kwargs)
- raise e
-
- async def aspeech(self, model: str, input: str, voice: str | None = None, **kwargs):
- """
- Example Usage:
-
- ```
- from litellm import Router
- client = Router(model_list = [
- {
- "model_name": "tts",
- "litellm_params": {
- "model": "tts-1",
- },
- },
- ])
-
- async with client.aspeech(
- model="tts",
- voice="alloy",
- input="the quick brown fox jumped over the lazy dogs",
- api_base=None,
- api_key=None,
- organization=None,
- project=None,
- max_retries=1,
- timeout=600,
- client=None,
- optional_params={},
- ) as response:
- response.stream_to_file(speech_file_path)
-
- ```
- """
- try:
- kwargs["model"] = model
- kwargs["input"] = input
- kwargs["voice"] = voice
- kwargs["original_function"] = self._aspeech
- self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
- response: Final = await self.async_function_with_fallbacks(**kwargs)
-
- return response
- except Exception as e:
- asyncio.create_task(
- send_llm_exception_alert(
- ctx=self._core_ctx,
- request_kwargs=kwargs,
- error_traceback_str=traceback.format_exc(),
- original_exception=e,
- )
- )
- raise e
-
- async def _aspeech(self, model: str, input: str, voice: str | None = None, **kwargs):
- model_name: Final = model
- try:
- verbose_router_logger.debug("Inside _aspeech()- model: %s", model)
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- deployment: Final = await self._core_async_get_available_deployment(
- model=model,
- messages=[{"role": "user", "content": "prompt"}],
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
-
- self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
- data: Final = deployment["litellm_params"].copy()
- model_client: Final = self._get_async_openai_model_client(
- deployment=deployment,
- kwargs=kwargs,
- )
-
- self.total_calls[model_name] += 1
- async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
- response = await litellm.aspeech(
- **{
- **data,
- "input": input,
- "voice": data.get("voice") if voice is None else voice,
- "client": model_client,
- **kwargs,
- }
- )
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.aspeech(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
- return response
- except Exception as e:
- verbose_router_logger.info("litellm.aspeech(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
- if model_name is not None:
- self.fail_calls[model_name] += 1
- self._stamp_retry_skip_deployment_id(e, kwargs)
- raise e
-
- async def arerank(self, model: str, **kwargs):
- try:
- kwargs["model"] = model
- kwargs["input"] = input
- kwargs["original_function"] = self._arerank
- self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
-
- response: Final = await self.async_function_with_fallbacks(**kwargs)
-
- return response
- except Exception as e:
- asyncio.create_task(
- send_llm_exception_alert(
- ctx=self._core_ctx,
- request_kwargs=kwargs,
- error_traceback_str=traceback.format_exc(),
- original_exception=e,
- )
- )
- raise e
-
- async def _arerank(self, model: str, **kwargs):
- model_name = None
- try:
- verbose_router_logger.debug("Inside _rerank()- model: %s", model)
- deployment: Final = await self._core_async_get_available_deployment(
- model=model,
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
- self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
- data: Final = deployment["litellm_params"].copy()
- model_name = data["model"]
-
- model_client: Final = self._get_async_openai_model_client(
- deployment=deployment,
- kwargs=kwargs,
- )
- self.total_calls[model_name] += 1
-
- response: Final = await litellm.arerank(
- **{
- **data,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs,
- }
- )
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.arerank(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
- return response
- except Exception as e:
- verbose_router_logger.info("litellm.arerank(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
- if model_name is not None:
- self.fail_calls[model_name] += 1
- self._stamp_retry_skip_deployment_id(e, kwargs)
- raise e
-
- def text_completion(
- self,
- model: str,
- prompt: str,
- is_retry: bool | None = False,
- is_fallback: bool | None = False,
- is_async: bool | None = False,
- **kwargs,
- ):
- messages: Final = [{"role": "user", "content": prompt}]
- try:
- kwargs["model"] = model
- kwargs["prompt"] = prompt
- kwargs["num_retries"] = kwargs.get("num_retries", self.num_retries)
- kwargs.setdefault("metadata", {}).update({"model_group": model})
-
- # pick the one that is available (lowest TPM/RPM)
- with endpoint_selection("text_completion", model=model):
- deployment: Final = self.get_available_deployment(
- model=model,
- messages=messages,
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
-
- data: Final = deployment["litellm_params"].copy()
- for k, v in self._core_default_litellm_params.items():
- if k not in kwargs: # prioritize model-specific params > default router params
- kwargs[k] = v
- elif k == "metadata":
- kwargs[k].update(v)
- pinned_model_id: Final = deployment_model_id_pin(_LITELLM_PARAMS_ADAPTER.validate_python(data))
- if pinned_model_id is not None:
- kwargs["model_id"] = pinned_model_id # rebind-ok: this call's own kwargs, filled like the defaults
-
- # call via litellm.completion()
- return litellm.text_completion(
- **{**data, "prompt": prompt, "caching": self._core_cache_responses, **kwargs}
- )
- except Exception as e:
- raise e
-
- async def atext_completion(
- self,
- model: str,
- prompt: str,
- is_retry: bool | None = False,
- is_fallback: bool | None = False,
- is_async: bool | None = False,
- **kwargs,
- ):
- try:
- kwargs["model"] = model
- kwargs["prompt"] = prompt
- kwargs["original_function"] = self._atext_completion
-
- self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
- request_priority, request_kwargs = self._core_split_request_priority(model=model, kwargs=kwargs)
- if request_priority is None:
- return await self.async_function_with_fallbacks(**request_kwargs)
- return await self._schedule_factory(
- model=model,
- priority=request_priority,
- original_function=self.async_function_with_fallbacks,
- args=(),
- kwargs=request_kwargs,
- )
- except Exception as e:
- asyncio.create_task(
- send_llm_exception_alert(
- ctx=self._core_ctx,
- request_kwargs=kwargs,
- error_traceback_str=traceback.format_exc(),
- original_exception=e,
- )
- )
- raise e
-
- async def _atext_completion(self, model: str, prompt: str, **kwargs):
- try:
- verbose_router_logger.debug("Inside _atext_completion()- model: %s", model)
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- deployment: Final = await self._core_async_get_available_deployment(
- model=model,
- messages=[{"role": "user", "content": prompt}],
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
- self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, splits_stream_timeout=True)
-
- data: Final = deployment["litellm_params"].copy()
- model_name: Final = data["model"]
-
- model_client: Final = self._get_async_openai_model_client(
- deployment=deployment,
- kwargs=kwargs,
- )
- self.total_calls[model_name] += 1
-
- async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
- response = await litellm.atext_completion(
- **{
- **data,
- "prompt": prompt,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs,
- }
- )
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.atext_completion(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
- return response
- except Exception as e:
- verbose_router_logger.info("litellm.atext_completion(model=%s)\x1b[31m Exception %s\x1b[0m", model, e)
- if model is not None:
- self.fail_calls[model] += 1
- self._stamp_retry_skip_deployment_id(e, kwargs)
- raise e
-
- async def aadapter_completion(
- self,
- adapter_id: str,
- model: str,
- is_retry: bool | None = False,
- is_fallback: bool | None = False,
- is_async: bool | None = False,
- **kwargs,
- ):
- try:
- kwargs["model"] = model
- kwargs["adapter_id"] = adapter_id
- kwargs["original_function"] = self._aadapter_completion
- kwargs.setdefault("metadata", {}).update({"model_group": model})
- response: Final = await self.async_function_with_fallbacks(**kwargs)
-
- return response
- except Exception as e:
- asyncio.create_task(
- send_llm_exception_alert(
- ctx=self._core_ctx,
- request_kwargs=kwargs,
- error_traceback_str=traceback.format_exc(),
- original_exception=e,
- )
- )
- raise e
-
- async def _aadapter_completion(self, adapter_id: str, model: str, **kwargs):
- try:
- verbose_router_logger.debug("Inside _aadapter_completion()- model: %s", model)
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- deployment: Final = await self._core_async_get_available_deployment(
- model=model,
- messages=[{"role": "user", "content": "default text"}],
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
- self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, splits_stream_timeout=True)
-
- data: Final = deployment["litellm_params"].copy()
- model_name: Final = data["model"]
-
- model_client: Final = self._get_async_openai_model_client(
- deployment=deployment,
- kwargs=kwargs,
- )
- self.total_calls[model_name] += 1
-
- async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
- response = await litellm.aadapter_completion(
- **{
- **data,
- "adapter_id": adapter_id,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs,
- }
- )
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.aadapter_completion(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
- return response
- except Exception as e:
- verbose_router_logger.info("litellm.aadapter_completion(model=%s)\x1b[31m Exception %s\x1b[0m", model, e)
- if model is not None:
- self.fail_calls[model] += 1
- self._stamp_retry_skip_deployment_id(e, kwargs)
- raise e
-
- async def _asearch_with_fallbacks(self, original_function: Callable, **kwargs) -> object:
- """
- Helper function to make a search API call through the router with load balancing and fallbacks.
- Reuses the router's retry/fallback infrastructure.
- """
- from litellm.router_utils.search_api_router import SearchAPIRouter
-
- return _OPAQUE_VALUE_ADAPTER.validate_python(
- await SearchAPIRouter.async_search_with_fallbacks(
- litellm_routing_ctx=self._core_ctx,
- original_function=original_function,
- **kwargs,
- )
- )
-
- async def _asearch_with_fallbacks_helper(self, model: str, original_generic_function: Callable, **kwargs):
- """
- Helper function for search API calls - selects a search tool and calls the original function.
- Called by async_function_with_fallbacks for each retry attempt.
- """
- from litellm.router_utils.search_api_router import SearchAPIRouter
-
- return await SearchAPIRouter.async_search_with_fallbacks_helper(
- litellm_routing_ctx=self._core_ctx,
- model=model,
- original_generic_function=original_generic_function,
- **kwargs,
- )
-
- async def aguardrail(
- self,
- guardrail_name: str,
- original_function: Callable,
- **kwargs,
- ):
- """
- Execute a guardrail with load balancing and fallbacks.
-
- Args:
- guardrail_name: Name of the guardrail to execute
- original_function: The guardrail's execution function (e.g., async_pre_call_hook)
- **kwargs: Additional arguments passed to the guardrail
-
- Returns:
- Result from the guardrail execution
- """
- kwargs["model"] = guardrail_name # For fallback system compatibility
- kwargs["original_generic_function"] = original_function
- kwargs["original_function"] = self._aguardrail_helper
- self._update_kwargs_before_fallbacks(
- model=guardrail_name,
- kwargs=kwargs,
- metadata_variable_name="litellm_metadata",
- )
- verbose_router_logger.debug("Inside aguardrail() - guardrail_name: %s", guardrail_name)
- response: Final = await self.async_function_with_fallbacks(**kwargs)
- return response
-
- async def _aguardrail_helper(
- self,
- model: str,
- original_generic_function: Callable,
- **kwargs,
- ):
- """
- Helper for aguardrail - selects a guardrail deployment and executes it.
- Called by async_function_with_fallbacks for each retry attempt.
-
- Args:
- model: The guardrail_name (named 'model' for fallback system compatibility)
- original_generic_function: The guardrail's execution function
- **kwargs: Additional arguments
- """
- guardrail_name: Final = model
- selected_guardrail: Final = self._core_get_available_guardrail(
- guardrail_name=guardrail_name,
- )
-
- verbose_router_logger.debug(
- "Selected guardrail deployment: %s", selected_guardrail.get("litellm_params", {}).get("guardrail")
- )
-
- # Pass the selected guardrail config to the original function
- kwargs["selected_guardrail"] = selected_guardrail
- response: Final = await original_generic_function(**kwargs)
- return response
-
- def _core_get_available_guardrail(
- self,
- guardrail_name: str,
- ) -> "GuardrailTypedDict":
- """
- Select a guardrail deployment using the router's load balancing strategy.
-
- Args:
- guardrail_name: Name of the guardrail to select
-
- Returns:
- Selected guardrail configuration dict
- """
- from litellm.router_strategy.simple_shuffle import simple_shuffle
-
- healthy_deployments: Final = [g for g in self._core_guardrail_list if g.get("guardrail_name") == guardrail_name]
-
- if not healthy_deployments:
- raise ValueError(f"No guardrail found with name: {guardrail_name}")
-
- if len(healthy_deployments) == 1:
- return healthy_deployments[0]
-
- # Use simple_shuffle for weighted selection
- return simple_shuffle(
- resolve_model_alias=self._core_get_model_from_alias,
- healthy_deployments=healthy_deployments,
- model=guardrail_name,
- request_kwargs=None,
- )
-
- async def _ageneric_api_call_with_fallbacks(
- self, model: str, original_function: Callable, attempt_function: Callable | None = None, **kwargs
- ):
- """
- Helper function to make a generic LLM API call through the router, this allows you to use retries/fallbacks with litellm router
-
- attempt_function runs every attempt of the chain instead of the plain helper, so a streaming
- endpoint can wrap each attempt's stream with its own mid-stream fallback handling.
- """
- try:
- kwargs["model"] = model
- kwargs["original_generic_function"] = original_function
- kwargs["original_function"] = attempt_function or self._ageneric_api_call_with_fallbacks_helper
- if attempt_function is not None:
- controls: Final = per_request_fallback_controls(kwargs)
- kwargs[MID_STREAM_FALLBACK_CONTROLS_KEY] = controls # rebind-ok: forwarded to every hop
- self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs, metadata_variable_name="litellm_metadata")
- verbose_router_logger.debug("Inside ageneric_api_call_with_fallbacks() - model: %s", model)
- response: Final = await self.async_function_with_fallbacks(**kwargs)
- return response
- except Exception as e:
- asyncio.create_task(
- send_llm_exception_alert(
- ctx=self._core_ctx,
- request_kwargs=kwargs,
- error_traceback_str=traceback.format_exc(),
- original_exception=e,
- )
- )
- raise e
-
- def _add_deployment_model_to_endpoint_for_llm_passthrough_route(
- self, kwargs: dict[str, Any], model: str, model_name: str
- ) -> dict[str, Any]:
- """
- Add the deployment model to the endpoint for LLM passthrough route.
-
- e.g for bedrock invoke users can pass endpoint as /model/special-bedrock-model/invoke
- it should be actually sent as /model/us.anthropic.claude-3-5-sonnet-20240620-v1:0/invoke
- """
- if "endpoint" in kwargs and kwargs["endpoint"]:
- # For provider-specific endpoints, strip the provider prefix from model_name
- # e.g., "bedrock/us.anthropic.claude-3-5-sonnet-20240620-v1:0" -> "us.anthropic.claude-3-5-sonnet-20240620-v1:0"
- from litellm import get_llm_provider
-
- try:
- # get_llm_provider returns (model_without_prefix, provider, api_key, api_base)
- stripped_model_name, _, _, _ = get_llm_provider(
- model=model_name,
- custom_llm_provider=kwargs.get("custom_llm_provider"),
- api_base=kwargs.get("api_base"),
- )
- replacement_model_name = stripped_model_name
- except Exception:
- # If get_llm_provider fails, fall back to using model_name as-is
- replacement_model_name = model_name
-
- kwargs["endpoint"] = replace_path_segment(kwargs["endpoint"], model, replacement_model_name)
- return kwargs
-
- async def _ageneric_api_call_with_fallbacks_helper(self, model: str, original_generic_function: Callable, **kwargs):
- """
- Helper function to make a generic LLM API call through the router, this allows you to use retries/fallbacks with litellm router
- """
-
- passthrough_on_no_deployment: Final = kwargs.pop("passthrough_on_no_deployment", False)
- function_name: Final = "_ageneric_api_call_with_fallbacks"
- handler_name: Final[str | None] = getattr(original_generic_function, "__name__", None)
- deployment = None # rebind-ok: pre-init so the except block can stamp a failure with no deployment picked
- try:
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- try:
- with self._video_call_pin(handler_name, kwargs), self._route_capability_pin(handler_name):
- deployment = ( # rebind-ok: set on success, see pre-init
- await self._core_async_get_available_deployment(
- model=model,
- request_kwargs=kwargs,
- messages=kwargs.get("messages", None),
- input=kwargs.get("input", None),
- specific_deployment=kwargs.pop("specific_deployment", None),
- )
- )
- except Exception as e:
- if passthrough_on_no_deployment:
- return await original_generic_function(model=model, **kwargs)
- raise e
-
- self._update_kwargs_with_deployment(
- deployment=deployment,
- kwargs=kwargs,
- function_name=function_name,
- splits_stream_timeout=handler_name in _STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS,
- opens_responses_socket=handler_name == "_aresponses_websocket",
- )
-
- data: Final = deployment["litellm_params"].copy()
- model_name: Final = data["model"]
- self.total_calls[model_name] += 1
-
- self._add_deployment_model_to_endpoint_for_llm_passthrough_route(
- kwargs=kwargs, model=model, model_name=model_name
- )
-
- custom_llm_provider: Final = provider_for_generic_call(data)
- generic_function_name: Final[str] = getattr(original_generic_function, "__name__", "")
-
- response_kwargs: Final = {
- **without_retrieve_stream_defaults(data, kwargs),
- "caching": self._core_cache_responses,
- **kwargs,
- "model": model_name,
- **_with_router_resolved_session_model(kwargs.get("session"), model_name),
- **(
- {"turn_admission": partial(self.async_admit_deployment_turn, model, deployment, kwargs)}
- if generic_function_name == "_aresponses_websocket"
- else {}
- ),
- }
- # Only set custom_llm_provider if it's not None
- if custom_llm_provider is not None:
- response_kwargs["custom_llm_provider"] = custom_llm_provider
-
- compacted_input: Final = await compact_to_fit(
- self._core_ctx,
- deployment,
- response_kwargs,
- surface_for_call(generic_function_name),
- )
- async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
- response = await original_generic_function(**compacted_input)
-
- if self._should_raise_anthropic_refusal_error(
- model=model,
- original_generic_function=original_generic_function,
- response=response,
- kwargs=kwargs,
- ):
- from litellm.llms.anthropic.messages.native.utils import (
- safeguard_refusal_error,
- )
-
- refusal_details: Final = cast(dict, response["stop_details"]) # cast-ok: gate verified the shape
- raise safeguard_refusal_error(model=model, stop_details=refusal_details)
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("ageneric_api_call_with_fallbacks(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
-
- return response
- except Exception as e:
- verbose_router_logger.info(
- "ageneric_api_call_with_fallbacks(model=%s)\x1b[31m Exception %s\x1b[0m", model, e
- )
- if model is not None:
- self.fail_calls[model] += 1
- if deployment is not None:
- self._set_deployment_num_retries_on_exception(e, deployment)
- self._stamp_failed_deployment_id_with_effective_model_info(e, deployment, kwargs)
- raise e
-
- async def _aresponses_with_streaming_fallbacks(
- self, original_function: Callable, **kwargs: Any
- ) -> Union["ResponsesAPIResponse", "BaseResponsesAPIStreamingIterator"]:
- """
- _ageneric_api_call_with_fallbacks for the Responses API, with every attempt's stream
- carrying its own mid-stream fallback handling
- (see _ageneric_api_call_with_fallbacks_responses_attempt).
- """
- return await self._ageneric_api_call_with_fallbacks(
- original_function=original_function,
- attempt_function=self._ageneric_api_call_with_fallbacks_responses_attempt,
- **kwargs,
- )
-
- async def _ageneric_api_call_with_fallbacks_responses_attempt(
- self,
- model: str,
- original_generic_function: Callable,
- **kwargs: object, # kwargs-ok: forwarded verbatim to the per-attempt helper, shape varies per call site
- ) -> Union["ResponsesAPIResponse", "BaseResponsesAPIStreamingIterator"]:
- """
- One attempt of the Responses API fallback chain. A streaming result is wrapped with
- _aresponses_streaming_iterator over this attempt's own kwargs, so a fallback hop that
- fails mid-stream resumes the original group's chain instead of re-raising; the name keeps
- _get_router_metadata_variable_name resolving to litellm_metadata for every hop.
- """
- from litellm.responses.streaming_iterator import (
- BaseResponsesAPIStreamingIterator,
- )
-
- controls: Final = kwargs.pop(MID_STREAM_FALLBACK_CONTROLS_KEY, None)
- hop_kwargs: Final = mid_stream_fallback_hop_kwargs(
- model=model, original_generic_function=original_generic_function, controls=controls, kwargs=kwargs
- )
- shared_logging: Final = kwargs.get("litellm_logging_obj")
- if isinstance(shared_logging, LiteLLMLogging):
- shared_logging.carry_failed_attempt_usage()
- response: Final = await self._ageneric_api_call_with_fallbacks_helper(
- model=model, original_generic_function=original_generic_function, **kwargs
- )
- carry_over_pre_routing_selection(live_kwargs=kwargs, snapshot=hop_kwargs)
- carry_over_routed_deployment(live_kwargs=kwargs, snapshot=hop_kwargs)
- if kwargs.get("stream") and isinstance(response, BaseResponsesAPIStreamingIterator):
- return await self._aresponses_streaming_iterator(response=response, initial_kwargs=hop_kwargs)
- return response
-
- async def _aanthropic_messages_streaming_iterator(
- self,
- response: AsyncIterator[bytes],
- initial_kwargs: dict[str, object], # mutable-ok: mutated in-place before re-entering the fallback chain
- ) -> AsyncIterator[bytes]:
- """
- Wrap an anthropic_messages (/v1/messages) streaming response so a
- mid-stream provider error triggers the Router's fallback chain
- (parity with _acompletion_streaming_iterator for the
- chat-completions path). See #24004.
-
- anthropic_messages goes through _ageneric_api_call_with_fallbacks
- rather than _acompletion, so the returned byte iterator is never
- wrapped by the chat-completions fallback handler. Two failure
- shapes land here:
- - the completion-bridge path (deployments with no native
- /v1/messages endpoint, via
- LiteLLMMessagesToCompletionTransformationHandler) already
- raises MidStreamFallbackError out of its underlying
- CustomStreamWrapper; this wrapper only needs to catch it.
- - a native Anthropic/Bedrock passthrough never raises anything
- for a provider SSE `event: error` frame (e.g. `overloaded_error`,
- `internal_server_error`) - it is forwarded to the client as-is -
- so this wrapper detects it via parse_anthropic_error_event and
- raises MidStreamFallbackError itself.
-
- Only an error before any real content (a content_block_delta frame)
- has reached the caller triggers a fallback attempt, mirroring the
- restriction _acompletion_streaming_iterator applies: once generated
- output has already reached the caller, retrying would start a
- second, overlapping Anthropic message lifecycle on the same SSE
- stream, so the error is left to propagate instead of being retried
- invisibly. A non-retriable client error (4xx other than 429) is
- never worth a fallback attempt either, so it is also left to
- propagate.
-
- Lifecycle/bookkeeping frames (message_start, content_block_start,
- ping, ...) do not by themselves disqualify a fallback attempt -
- Anthropic routinely sends message_start before an overload error.
- When a fallback can still take over they are BUFFERED rather than
- forwarded immediately, since forwarding one and then appending a
- fallback attempt's own message_start would produce two overlapping
- message lifecycles on one SSE stream; a `ping` carries no lifecycle,
- so it is forwarded live even while lifecycle frames sit buffered,
- keeping the connection alive during a long thinking pass. Buffered
- frames are flushed, in order, the moment real content arrives (the
- primary attempt has committed by then anyway) or once the stream
- ends without ever producing content or an error. When no fallback
- can take over the request is already committed, so every frame,
- including pings and provider error frames, is forwarded live and
- verbatim instead.
- """
- from litellm.llms.anthropic.messages.native.fake_stream_iterator import (
- FakeAnthropicMessagesStreamIterator,
- )
- from litellm.llms.anthropic.messages.native.streaming_iterator import (
- aclose_if_supported,
- aclose_superseded_attempt,
- parse_anthropic_error_event,
- parse_anthropic_refusal_stop_details,
- )
- from litellm.llms.anthropic.messages.native.utils import (
- safeguard_refusal_error,
- )
-
- source_iterator: Final = response
- replays_a_finished_agentic_turn: Final = isinstance(source_iterator, FakeAnthropicMessagesStreamIterator)
-
- async def close_source() -> None:
- with anyio.CancelScope(shield=True), contextlib.suppress(BaseException):
- await aclose_if_supported(source_iterator)
-
- async def close_superseded() -> None:
- with anyio.CancelScope(shield=True), contextlib.suppress(BaseException):
- await aclose_superseded_attempt(source_iterator)
-
- async def stream_with_fallbacks() -> AsyncGenerator[bytes, None]:
- from litellm.exceptions import MidStreamFallbackError
-
- # Lifecycle/bookkeeping frames (message_start, content_block_start,
- # ...) are held back rather than forwarded immediately, but only
- # while a fallback can still take over: Anthropic routinely sends
- # message_start before an overload error, and once a byte reaches
- # the client a fallback attempt can only append its OWN
- # message_start, producing two overlapping message lifecycles on
- # one SSE stream. A `ping` keepalive carries no lifecycle, so it
- # is forwarded live even behind buffered frames, keeping the
- # connection alive through a long thinking pass. Buffered frames
- # are flushed the moment real content (content_block_delta)
- # arrives - at that point the primary attempt has committed and a
- # clean retry is no longer possible anyway - or once the primary
- # stream ends without ever producing content. Hitting
- # MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS forces the same early
- # commit as real content arriving, so a hostile or pathological
- # upstream can't grow the buffer forever. With no fallback able
- # to take over there is nothing to buffer for, so every frame,
- # including pings and provider error frames, is forwarded live.
- model: Final = cast(str, initial_kwargs.get("model")) # cast-ok: kwargs always carries the model group
- has_generated_content = not ( # rebind-ok: set once real content is seen, the buffer cap is hit, or neither a retry nor a fallback can take over
- self._anthropic_messages_stream_can_retry(initial_kwargs)
- or self._stream_can_fall_back(model, initial_kwargs)
- )
- buffered_lifecycle_chunks: tuple[bytes, ...] = () # rebind-ok: flushed once committed or on decline
- try:
- async for chunk in source_iterator:
- if _anthropic_stream_forwards_ping_live(chunk, has_generated_content):
- yield chunk
- continue
- if _stream_commits_now(chunk, has_generated_content, len(buffered_lifecycle_chunks)):
- has_generated_content = True
- # A transport can split one SSE data line across byte chunks, so pre-content
- # detection parses the accumulated buffer plus the current chunk, never the
- # chunk alone; the buffer is already capped, which bounds this window too.
- parse_window = (
- b"".join(c for c in (*buffered_lifecycle_chunks, chunk) if isinstance(c, (bytes, bytearray))) # pyright: ignore[reportUnnecessaryIsInstance] # bridge-path chunks are not always bytes at runtime
- if not has_generated_content and isinstance(chunk, (bytes, bytearray)) # pyright: ignore[reportUnnecessaryIsInstance] # bridge-path chunks are not always bytes at runtime
- else chunk
- )
- error_event = parse_anthropic_error_event(parse_window)
- recoverable_frame_error = self._anthropic_messages_recoverable_frame_error(
- error_event, chunk, has_generated_content, model, initial_kwargs
- )
- refusal_stop_details = (
- parse_anthropic_refusal_stop_details(parse_window)
- if not has_generated_content and error_event is None
- else None
- )
- if (
- refusal_stop_details is not None
- and not replays_a_finished_agentic_turn
- and self._refusal_fallback_available(model, initial_kwargs)
- ):
- refusal_error = safeguard_refusal_error(model=model, stop_details=refusal_stop_details)
- raise MidStreamFallbackError(
- message=refusal_error.message,
- model=model,
- llm_provider="anthropic",
- original_exception=refusal_error,
- is_pre_first_chunk=True,
- )
- if not has_generated_content and error_event is None:
- buffered_lifecycle_chunks = (*buffered_lifecycle_chunks, chunk)
- continue
- if recoverable_frame_error is not None:
- assert error_event is not None
- raise MidStreamFallbackError(
- message=error_event[1],
- model=model,
- llm_provider="anthropic",
- original_exception=recoverable_frame_error,
- is_pre_first_chunk=True,
- )
- for buffered_chunk in buffered_lifecycle_chunks:
- yield buffered_chunk
- buffered_lifecycle_chunks = ()
- yield chunk
- for buffered_chunk in buffered_lifecycle_chunks:
- yield buffered_chunk
- except Exception as stream_error: # noqa: BLE001 # any raised provider error must reach the fallback gate
- async for item in self._aanthropic_messages_recover_stream_error(
- stream_error,
- has_generated_content,
- buffered_lifecycle_chunks,
- model,
- initial_kwargs,
- wrapper,
- close_superseded,
- ):
- yield item
- finally:
- await close_source()
-
- # Referenced by stream_with_fallbacks via closure - assigned here, before
- # the generator body ever runs, so the reference resolves fine despite
- # being defined textually after the function that captures it.
- wrapper: Final = FallbackAwareAnthropicMessagesStream(stream_with_fallbacks(), source_iterator)
- return wrapper
-
- async def _aanthropic_messages_recover_stream_error(
- self,
- stream_error: Exception,
- has_generated_content: bool,
- buffered_lifecycle_chunks: tuple[bytes, ...],
- model: str,
- initial_kwargs: dict[str, object], # mutable-ok: _aanthropic_messages_fallback_attempt mutates it
- wrapper: "FallbackAwareAnthropicMessagesStream",
- close_superseded: Callable[[], Awaitable[None]],
- ) -> AsyncGenerator[bytes, None]:
- """Turns a source-iterator failure into a fallback attempt or the error reaching the caller."""
- from litellm.exceptions import MidStreamFallbackError
-
- if isinstance(stream_error, MidStreamFallbackError) and _anthropic_stream_should_decline_fallback(
- has_generated_content, stream_error
- ):
- for buffered_chunk in buffered_lifecycle_chunks:
- yield buffered_chunk
- if stream_error.original_exception is not None:
- raise stream_error.original_exception from stream_error
- raise stream_error
- fallback_error: Final = (
- stream_error
- if isinstance(stream_error, MidStreamFallbackError)
- else _stream_fallback_error_for_raised(stream_error, model, has_generated_content)
- )
- if fallback_error is None:
- raise stream_error
- await close_superseded()
- outcome: Final = await self._astream_retry_same_group(
- fallback_error,
- initial_kwargs,
- attempt_function=self._ageneric_api_call_with_fallbacks_anthropic_messages_attempt,
- )
- match outcome:
- case _StreamRetryOpened(response=retried, attempted_retries=attempted, max_retries=budget):
- async for item in self._aanthropic_messages_yield_recovered(retried, wrapper, (attempted, budget)):
- yield item
- case _StreamRetriesExhausted(error=last_error):
- async for item in self._aanthropic_messages_fallback_attempt(last_error, initial_kwargs, wrapper):
- yield item
- case _:
- assert_never(outcome)
-
- def _anthropic_messages_group_retry_policy(self, kwargs: Mapping[str, object]) -> dict[str, RetryPolicy] | None:
- configured: Final = kwargs.get("model_group_retry_policy", self.model_group_retry_policy)
- return cast("dict[str, RetryPolicy] | None", configured) # cast-ok: same type as the router attribute
-
- def _anthropic_messages_resolved_retry_policy(self, kwargs: Mapping[str, object]) -> RetryPolicy | None:
- """
- The retry policy for this request's model group, unless the request opted out with num_retries=0.
- A policy that does not resolve to a RetryPolicy governs nothing here, so the stream runs as it would
- with none; the pre-stream retry loop still reports the malformed policy when an attempt fails.
- """
- if kwargs.get("num_retries") == 0:
- return None
- model_group: Final = _request_model_group(kwargs)
- try:
- return _RESOLVED_RETRY_POLICY_ADAPTER.validate_python(
- resolve_retry_policy(
- retry_policy=self.retry_policy,
- model_group=model_group,
- model_group_retry_policy=self._anthropic_messages_group_retry_policy(kwargs),
- )
- )
- except (TypeError, ValidationError) as malformed:
- verbose_router_logger.warning(
- "The retry policy for %s is not a RetryPolicy, streaming without one: %s", model_group, malformed
- )
- return None
-
- def _anthropic_messages_plain_retry_budget(self, kwargs: Mapping[str, object]) -> int:
- """
- The same precedence async_function_with_retries resolves for a failure raised before the
- stream opened: the request's num_retries, then the routed deployment's, then the router's.
- """
- request_num_retries: Final = kwargs.get("num_retries")
- if isinstance(request_num_retries, int):
- return request_num_retries
- deployment_id: Final = routed_deployment_id(kwargs)
- deployment: Final = self._core_get_deployment(deployment_id) if deployment_id is not None else None
- deployment_num_retries: Final = _deployment_num_retries(deployment)
- if deployment_num_retries is not None:
- return deployment_num_retries
- return self.num_retries if self.num_retries is not None else 0
-
- def _anthropic_messages_policy_retries(self, trigger: Exception, kwargs: Mapping[str, object]) -> int | None:
- policy: Final = self._anthropic_messages_resolved_retry_policy(kwargs)
- if policy is None:
- return None
- return _get_num_retries_from_retry_policy(exception=trigger, retry_policy=policy)
-
- def _stream_retry_budget(self, trigger: Exception, kwargs: Mapping[str, object]) -> tuple[int, bool]:
- """
- The budget an earlier retry of this request committed to, else the retry policy's grant when one
- names this error, else the plain budget, with whether a policy governs the retry: a committed budget
- is kept whichever deployment the retry lands on, as async_function_with_retries keeps its own.
- """
- policy_retries: Final = self._anthropic_messages_policy_retries(trigger, kwargs)
- committed_budget: Final = committed_retry_budget_for_request(kwargs)
- if committed_budget is not None:
- return committed_budget, policy_retries is not None
- if policy_retries is None:
- return self._anthropic_messages_plain_retry_budget(kwargs), False
- return policy_retries, True
-
- def _anthropic_messages_stream_can_retry(self, kwargs: Mapping[str, object]) -> bool:
- """
- Whether a pre-content failure of this stream would be retried within its own model group,
- the other case where holding lifecycle frames back from the client buys a clean restart.
- A retry policy names its budget per error class, so the largest budget it names bounds the
- hold: holding frames one attempt too long is safe, forwarding them before a retry is not.
- """
- attempted: Final = attempted_retries_for_request(kwargs)
- committed_budget: Final = committed_retry_budget_for_request(kwargs)
- if committed_budget is not None:
- return committed_budget > attempted
- plain_budget: Final = self._anthropic_messages_plain_retry_budget(kwargs)
- policy: Final = self._anthropic_messages_resolved_retry_policy(kwargs)
- ceiling: Final = plain_budget if policy is None else max(plain_budget, _retry_policy_ceiling(policy))
- return ceiling > attempted
-
- def _anthropic_messages_recoverable_frame_error(
- self,
- error_event: tuple[str, str, int] | None,
- chunk: object,
- has_generated_content: bool,
- model_group: str,
- kwargs: Mapping[str, object],
- ) -> Exception | None:
- """
- The exception a provider `event: error` frame before content recovers through when a retry of its
- class or a fallback can still take over. A frame nothing can take over for (content already out, a
- gateway verdict, or a class granted no retry with no fallback) reaches the client as the provider
- sent it, the way the last exhausted attempt's does.
- """
- if has_generated_content or error_event is None:
- return None
- error_type, message, status_code = error_event
- if not _is_retriable_anthropic_status(status_code) or _anthropic_stream_error_is_gateway_verdict(chunk):
- return None
- frame_error: Final = anthropic_error_frame_exception(error_type, message, status_code, model_group)
- budget, _ = self._stream_retry_budget(frame_error, kwargs)
- if budget > attempted_retries_for_request(kwargs):
- return frame_error
- if self._stream_can_fall_back(model_group, kwargs):
- return frame_error
- return None
-
- def _stream_should_retry(
- self,
- trigger: Exception,
- healthy_deployments: list[dict], # mutable-ok: should_retry_this_error's own parameter type
- all_deployments: list[dict], # mutable-ok: should_retry_this_error's own parameter type
- kwargs: Mapping[str, object],
- ) -> bool:
- try:
- self.should_retry_this_error(
- error=trigger,
- healthy_deployments=healthy_deployments,
- all_deployments=all_deployments,
- context_window_fallbacks=_request_fallback_list(
- kwargs,
- "context_window_fallbacks",
- cast( # cast-ok: untyped router attribute
- "list[object] | None", self._core_context_window_fallbacks
- ),
- ),
- content_policy_fallbacks=_request_fallback_list(
- kwargs,
- "content_policy_fallbacks",
- cast( # cast-ok: untyped router attribute
- "list[object] | None", self._core_content_policy_fallbacks
- ),
- ),
- regular_fallbacks=_request_fallback_list(
- kwargs,
- "fallbacks",
- cast("list[object] | None", self._core_fallbacks), # cast-ok: untyped router attribute
- ),
- )
- except Exception: # noqa: BLE001 # should_retry_this_error declines by raising the error it was given
- return False
- return True
-
- async def _astream_retry_same_group(
- self,
- e: "MidStreamFallbackError",
- initial_kwargs: Mapping[str, object],
- attempt_function: "Callable[..., Awaitable[object]]",
- ) -> _StreamRetryOutcome:
- """
- Re-runs the attempt within the request's own model group, the way async_function_with_retries
- would have for a failure raised before the stream opened, until a retry opens a stream or the
- budget runs out. Each retry's stream carries its own wrapper with the remaining budget, so a
- retry that drops before content again continues the same count instead of starting over.
- `attempt_function` is the request's own attempt (the /v1/messages attempt, or a chat completions one).
- """
- model_group: Final = cast(str, initial_kwargs.get("model")) # cast-ok: kwargs always carries the model group
- retry_kwargs: Final = mid_stream_retry_kwargs(initial_kwargs)
- handler: Final = retry_kwargs.get("original_generic_function")
- healthy_deployments, all_deployments = await self._async_get_healthy_deployments(
- model=model_group,
- parent_otel_span=get_parent_otel_span_from_kwargs(retry_kwargs),
- request_kwargs=share_holder_request_kwargs(initial_kwargs, self._ptu_share_teams()),
- )
- budget, policy_applies = self._stream_retry_budget(_mid_stream_retry_trigger(e), initial_kwargs)
- last_error = e # rebind-ok: the newest failure is what the fallback chain and the caller see
- for attempt in range(attempted_retries_for_request(initial_kwargs), budget):
- trigger = _mid_stream_retry_trigger(last_error)
- if not policy_applies and not self._stream_should_retry(
- trigger, healthy_deployments, all_deployments, initial_kwargs
- ):
- return _StreamRetriesExhausted(last_error)
- if get_metadata_variable_name_from_kwargs(retry_kwargs) in retry_kwargs:
- self._core_log_retry(kwargs=retry_kwargs, e=trigger)
- await asyncio.sleep(
- self._time_to_sleep_before_retry(
- e=trigger,
- remaining_retries=budget - attempt,
- num_retries=budget,
- healthy_deployments=healthy_deployments,
- all_deployments=all_deployments,
- )
- )
- record_retry_attempt(retry_kwargs, attempted_retries=attempt + 1, max_retries=budget)
- verbose_router_logger.debug(
- "Retrying stream that failed before content, attempt %s of %s", attempt + 1, budget
- )
- carry_superseded_attempt_usage(retry_kwargs)
- try:
- with endpoint_selection(
- getattr(handler, "__name__", None), getattr(handler, "__module__", None), model=model_group
- ):
- response = await attempt_function(**retry_kwargs)
- except Exception as retry_error: # noqa: BLE001 # the fallback chain judges the failed retry
- wrapped = _stream_fallback_error_for_raised(retry_error, model_group, False, e.llm_provider)
- if wrapped is None:
- return _StreamRetriesExhausted(_stream_pre_content_error(retry_error, model_group, e.llm_provider))
- last_error = wrapped
- continue
- return _StreamRetryOpened(response, attempted_retries=attempt + 1, max_retries=budget)
- return _StreamRetriesExhausted(last_error)
-
- async def _aanthropic_messages_yield_recovered(
- self,
- recovered: object,
- wrapper: "FallbackAwareAnthropicMessagesStream",
- retry_counters: tuple[int, int] | None = None,
- ) -> AsyncGenerator[bytes, None]:
- """
- Hands a retry's or a fallback's response to the client through the wrapper, closing it afterwards.
- A retry stamps the retry headers async_function_with_retries would have for a pre-stream retry;
- a later hop the recovered stream makes replaces them with its own, the way a fallback's do.
- """
- from litellm.llms.anthropic.messages.native.streaming_iterator import (
- aclose_if_supported,
- anthropic_messages_response_as_sse_events,
- )
-
- hidden_params, headers = Router._prepare_fallback_hidden_params(recovered)
- wrapper.merge_fallback_hidden_params(hidden_params, headers)
- wrapper.adopt_fallback_source(recovered)
- if retry_counters is not None:
- add_retry_headers_to_response(
- response=wrapper, attempted_retries=retry_counters[0], max_retries=retry_counters[1]
- )
- try:
- if hasattr(recovered, "__aiter__"):
- async for item in cast("AsyncIterator[bytes]", recovered): # cast-ok: __aiter__ checked above
- wrapper.follow_source_attribution()
- yield item
+ if _class_descriptor(type(self), name):
+ object.__setattr__(self, name, value)
return
- # A recovery can resolve to a complete AnthropicMessagesResponse
- # dict even for a streaming request (e.g. an agentic tool-use
- # interception loop) - yielding it as-is would put a raw dict
- # into a byte stream, so it's synthesized into the SSE
- # lifecycle a real stream would have sent instead.
- for event in anthropic_messages_response_as_sse_events(
- cast("AnthropicMessagesResponse", recovered) # cast-ok: non-streaming shape by elimination
- ):
- yield event
- finally:
- with anyio.CancelScope(shield=True), contextlib.suppress(BaseException):
- await aclose_if_supported(recovered)
-
- async def _aanthropic_messages_fallback_attempt(
- self,
- e: "MidStreamFallbackError",
- initial_kwargs: dict[str, Any], # mutable-ok: mutated in-place before re-entering the fallback chain
- wrapper: "FallbackAwareAnthropicMessagesStream",
- ) -> AsyncGenerator[bytes, None]:
- """
- Re-enters the Router's fallback chain for a mid-stream
- anthropic_messages error and yields whatever the fallback attempt
- produces. Split out of _aanthropic_messages_streaming_iterator to
- keep each function's cyclomatic complexity within the repo's C901
- budget.
- """
- from litellm.exceptions import MidStreamFallbackError
-
- try:
- model_group: Final = cast(str, initial_kwargs.get("model")) # cast-ok: model group
- fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the common_utils list|None param
- "fallbacks", self._core_fallbacks
- )
- context_window_fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the param below
- "context_window_fallbacks", self._core_context_window_fallbacks
- )
- content_policy_fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the param below
- "content_policy_fallbacks", self._core_content_policy_fallbacks
- )
- initial_kwargs["original_function"] = self._ageneric_api_call_with_fallbacks_anthropic_messages_attempt
- self._update_kwargs_before_fallbacks(
- model=model_group,
- kwargs=initial_kwargs,
- metadata_variable_name="litellm_metadata",
- )
- # The content-policy and context-window dispatch branches match on the trigger's own type, so
- # such an error's MidStreamFallbackError envelope is unwrapped here or the wrong fallback list is consulted.
- fallback_trigger: Final[Exception] = (
- e.original_exception
- if isinstance(
- e.original_exception, (litellm.ContentPolicyViolationError, litellm.ContextWindowExceededError)
- )
- else e
- )
- fallback_response: Final = _OPAQUE_VALUE_ADAPTER.validate_python(
- await self.async_function_with_fallbacks_common_utils(
- e=fallback_trigger,
- disable_fallbacks=fallbacks_disabled_for_request(initial_kwargs),
- fallbacks=fallbacks,
- context_window_fallbacks=context_window_fallbacks,
- content_policy_fallbacks=content_policy_fallbacks,
- model_group=model_group,
- args=(),
- kwargs=initial_kwargs,
- include_fallback_errors=initial_kwargs.get("include_fallback_errors", False) is True,
- )
- )
- async for fallback_item in self._aanthropic_messages_yield_recovered(fallback_response, wrapper):
- yield fallback_item
- except Exception as fallback_error:
- verbose_router_logger.error("Anthropic messages streaming fallback also failed: %s", fallback_error)
- if isinstance(fallback_error, MidStreamFallbackError) and fallback_error.original_exception is not None:
- raise fallback_error.original_exception from fallback_error
- raise
-
- async def _aanthropic_messages_with_streaming_fallbacks(
- self,
- original_function: Callable,
- **kwargs: object, # kwargs-ok: forwarded verbatim to original_function, shape varies per call site
- ) -> Union["AnthropicMessagesResponse", AsyncIterator[bytes]]:
- """
- _ageneric_api_call_with_fallbacks for anthropic_messages, with every attempt's stream
- carrying its own mid-stream fallback handling
- (see _ageneric_api_call_with_fallbacks_anthropic_messages_attempt). Parity with
- _aresponses_with_streaming_fallbacks for the Responses API.
- """
- return await self._ageneric_api_call_with_fallbacks(
- original_function=original_function,
- attempt_function=self._ageneric_api_call_with_fallbacks_anthropic_messages_attempt,
- **kwargs,
- )
-
- async def _ageneric_api_call_with_fallbacks_anthropic_messages_attempt(
- self,
- model: str,
- original_generic_function: Callable[..., Awaitable[object]],
- **kwargs: object, # kwargs-ok: forwarded verbatim to the per-attempt helper, shape varies per call site
- ) -> Union["AnthropicMessagesResponse", AsyncIterator[bytes]]:
- """
- One attempt of the anthropic_messages fallback chain. A streaming result is wrapped with
- _aanthropic_messages_streaming_iterator over this attempt's own kwargs, so a fallback hop
- that fails mid-stream resumes the original group's chain instead of re-raising; the name
- keeps _get_router_metadata_variable_name resolving to litellm_metadata for every hop.
- """
- controls: Final = kwargs.pop(MID_STREAM_FALLBACK_CONTROLS_KEY, None)
- hop_kwargs: Final = mid_stream_fallback_hop_kwargs(
- model=model, original_generic_function=original_generic_function, controls=controls, kwargs=kwargs
- )
- response: Final = await self._ageneric_api_call_with_fallbacks_helper(
- model=model, original_generic_function=original_generic_function, **kwargs
- )
- carry_over_pre_routing_selection(live_kwargs=kwargs, snapshot=hop_kwargs)
- carry_over_routed_deployment(live_kwargs=kwargs, snapshot=hop_kwargs)
- if kwargs.get("stream") and hasattr(response, "__aiter__"):
- return await self._aanthropic_messages_streaming_iterator(
- response=cast("AsyncIterator[bytes]", response), # cast-ok: stream=True always returns a byte iterator
- initial_kwargs=hop_kwargs,
- )
- return response
-
- async def _dispatch_generic_call_type(
- self,
- call_type: str,
- original_function: Callable,
- **kwargs: object, # kwargs-ok: forwarded verbatim to the per-call-type helper, shape varies per call site
- ):
- """
- factory_function's shared dispatch for call types with no
- call-specific handling, except anthropic_messages: kept out of
- factory_function's own async_wrapper (already at the repo's C901
- complexity ceiling) so routing its mid-stream fallback handling
- (#24004) doesn't add another branch there.
- """
- if call_type == "anthropic_messages":
- return await self._aanthropic_messages_with_streaming_fallbacks(
- original_function=original_function, **kwargs
- )
- return await self._ageneric_api_call_with_fallbacks(original_function=original_function, **kwargs)
-
- @staticmethod
- @contextlib.contextmanager
- def _video_call_pin(handler_name: str | None, request_kwargs: Mapping[str, object]) -> Generator[None]:
- """
- Scopes the deployment a video id names to the routing of one call on that video (status, content, remix,
- edit, extension). Any other call routes as if no id were sent, whatever its body carries.
- """
- video_id: Final = request_kwargs.get("video_id")
- deployment_id: Final = (
- decode_video_id_with_provider(video_id).get("deployment_id")
- if handler_name in VIDEO_ID_CALL_TYPES and isinstance(video_id, str)
- else None
- )
- token: Final = _video_call_deployment_id.set(deployment_id)
- try:
- yield
- finally:
- _video_call_deployment_id.reset(token)
-
- @staticmethod
- @contextlib.contextmanager
- def _route_capability_pin(handler_name: str | None) -> Generator[None]:
- token: Final = _routed_call_type.set(handler_name)
- try:
- yield
- finally:
- _routed_call_type.reset(token)
-
- @staticmethod
- def _narrow_to_video_deployment(healthy_deployments: Sequence[_DeploymentT]) -> Sequence[_DeploymentT]:
- """
- A video lives on the provider account of the deployment that created it, so a call on an id naming a
- deployment that every routing filter kept goes to that deployment, else to whichever one routing picks.
- """
- deployment_id: Final = _video_call_deployment_id.get()
- creating: Final = tuple(
- deployment for deployment in healthy_deployments if deployment_id in Router._deployment_ids((deployment,))
- )
- return creating or healthy_deployments
-
- def _generic_api_call_with_fallbacks(self, model: str, original_function: Callable, **kwargs):
- """
- Make a generic LLM API call through the router, this allows you to use retries/fallbacks with litellm router
- Args:
- model: The model to use
- original_function: The handler function to call (e.g., litellm.completion)
- **kwargs: Additional arguments to pass to the handler function
- Returns:
- The response from the handler function
- """
- handler_name: Final = original_function.__name__
- metadata_variable_name: Final = get_router_metadata_variable_name(function_name="generic_api_call")
- try:
- verbose_router_logger.debug("Inside _generic_api_call() - handler: %s, model: %s", handler_name, model)
- self._update_kwargs_before_fallbacks(
- model=model,
- kwargs=kwargs,
- metadata_variable_name=metadata_variable_name,
- )
- with self._video_call_pin(handler_name, kwargs), self._route_capability_pin(handler_name):
- deployment: Final = self.get_available_deployment(
- model=model,
- messages=kwargs.get("messages", None),
- input=kwargs.get("input", None),
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
- self._update_kwargs_with_deployment(
- deployment=deployment,
- kwargs=kwargs,
- function_name="generic_api_call",
- splits_stream_timeout=handler_name in _STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS,
- )
-
- data: Final = deployment["litellm_params"].copy()
- model_name: Final = data["model"]
-
- self.total_calls[model_name] += 1
-
- # For passthrough routes, use the actual model from deployment
- # and swap model name in endpoint if present
- if "endpoint" in kwargs and kwargs["endpoint"]:
- kwargs["endpoint"] = kwargs["endpoint"].replace(model, model_name)
- kwargs["model"] = model_name
-
- # Perform pre-call checks for routing strategy
- self.routing_strategy_pre_call_checks(deployment=deployment)
-
- custom_llm_provider: Final = provider_for_generic_call(data)
-
- response: Final = original_function(
- **{
- **data,
- "custom_llm_provider": custom_llm_provider,
- "caching": self._core_cache_responses,
- **kwargs,
- }
- )
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("%s(model=%s)\x1b[32m 200 OK\x1b[0m", handler_name, model_name)
- return response
- except Exception as e:
- verbose_router_logger.info("%s(model=%s)\x1b[31m Exception %s\x1b[0m", handler_name, model, e)
- if model is not None:
- self.fail_calls[model] += 1
- raise e
-
- def embedding(
- self,
- model: str,
- input: str | list,
- is_async: bool | None = False,
- **kwargs,
- ) -> EmbeddingResponse:
- try:
- kwargs["model"] = model
- kwargs["input"] = input
- kwargs["original_function"] = self._embedding
- self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
- response: Final = self.function_with_fallbacks(**kwargs)
- return response
- except Exception as e:
- raise e
-
- def _embedding(self, input: str | list[object], model: str, **kwargs):
- model_name = None
- try:
- verbose_router_logger.debug("Inside embedding()- model: %s", model)
- with embedding_deployment_selection():
- deployment: Final = self.get_available_deployment(
- model=model,
- input=input,
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
- self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
- data: Final = deployment["litellm_params"].copy()
- model_name = data["model"]
- batch_error: Final = embedding_batch_too_large_error(
- deployment=_OPAQUE_VALUE_ADAPTER.validate_python(deployment),
- input=input,
- request_kwargs=_OPAQUE_VALUE_ADAPTER.validate_python(kwargs),
- model=model,
- )
- logging_obj: Final = _REQUEST_KWARGS_ADAPTER.validate_python(kwargs).get("litellm_logging_obj")
- if batch_error is not None:
- if isinstance(logging_obj, LiteLLMLogging):
- log_rejection_before_provider_call(logging_obj, batch_error)
- raise batch_error
- if isinstance(logging_obj, LiteLLMLogging):
- clear_rejected_before_provider_call(logging_obj)
-
- potential_model_client: Final = self._get_client(deployment=deployment, kwargs=kwargs, client_type="sync")
- # check if provided keys == client keys #
- dynamic_api_key: Final = kwargs.get("api_key", None)
- if (
- dynamic_api_key is not None
- and potential_model_client is not None
- and dynamic_api_key != potential_model_client.api_key
- ):
- model_client = None
- else:
- model_client = potential_model_client
-
- self.total_calls[model_name] += 1
-
- ### DEPLOYMENT-SPECIFIC PRE-CALL CHECKS ### (e.g. update rpm pre-call. Raise error, if deployment over limit)
- self.routing_strategy_pre_call_checks(deployment=deployment)
-
- response: Final = litellm.embedding(
- **{
- **data,
- "input": input,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs,
- }
- )
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.embedding(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
- return response
- except Exception as e:
- verbose_router_logger.info("litellm.embedding(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
- if model_name is not None:
- self.fail_calls[model_name] += 1
- self._stamp_retry_skip_deployment_id(e, kwargs)
- raise e
-
- async def aembedding(
- self,
- model: str,
- input: str | list,
- is_async: bool | None = True,
- **kwargs,
- ) -> EmbeddingResponse:
- try:
- kwargs["model"] = model
- kwargs["input"] = input
- kwargs["original_function"] = self._aembedding
- self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
- response: Final = await self.async_function_with_fallbacks(**kwargs)
- return response
- except Exception as e:
- asyncio.create_task(
- send_llm_exception_alert(
- ctx=self._core_ctx,
- request_kwargs=kwargs,
- error_traceback_str=traceback.format_exc(),
- original_exception=e,
- )
- )
- raise e
-
- async def _aembedding(self, input: str | list[object], model: str, **kwargs):
- model_name = None
- try:
- verbose_router_logger.debug("Inside _aembedding()- model: %s", model)
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- with embedding_deployment_selection():
- deployment: Final = await self._core_async_get_available_deployment(
- model=model,
- input=input,
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
- self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
- data: Final = deployment["litellm_params"].copy()
- model_name = data["model"]
- batch_error: Final = embedding_batch_too_large_error(
- deployment=_OPAQUE_VALUE_ADAPTER.validate_python(deployment),
- input=input,
- request_kwargs=_OPAQUE_VALUE_ADAPTER.validate_python(kwargs),
- model=model,
- )
- logging_obj: Final = _REQUEST_KWARGS_ADAPTER.validate_python(kwargs).get("litellm_logging_obj")
- if batch_error is not None:
- if isinstance(logging_obj, LiteLLMLogging):
- await async_log_rejection_before_provider_call(logging_obj, batch_error)
- raise batch_error
- if isinstance(logging_obj, LiteLLMLogging):
- clear_rejected_before_provider_call(logging_obj)
- model_client: Final = self._get_async_openai_model_client(
- deployment=deployment,
- kwargs=kwargs,
- )
-
- self.total_calls[model_name] += 1
- async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
- response = await litellm.aembedding(
- **{
- **data,
- "input": input,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs,
- }
- )
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.aembedding(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
- return response
- except Exception as e:
- verbose_router_logger.info("litellm.aembedding(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
- if model_name is not None:
- self.fail_calls[model_name] += 1
- self._stamp_retry_skip_deployment_id(e, kwargs)
- raise e
-
- #### FILES API ####
- async def acreate_file(
- self,
- model: str,
- **kwargs,
- ) -> OpenAIFileObject:
- try:
- kwargs["model"] = model
- kwargs["original_function"] = self._acreate_file
- self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
- response: Final = await self.async_function_with_fallbacks(**kwargs)
-
- return response
- except Exception as e:
- asyncio.create_task(
- send_llm_exception_alert(
- ctx=self._core_ctx,
- request_kwargs=kwargs,
- error_traceback_str=traceback.format_exc(),
- original_exception=e,
- )
- )
- raise e
-
- async def _acreate_file(
- self,
- model: str,
- **kwargs,
- ) -> OpenAIFileObject:
- try:
- from litellm.litellm_core_utils.routing_common_utils import add_model_file_id_mappings
-
- verbose_router_logger.debug("Inside _atext_completion()- model: %s", model)
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- healthy_deployments: Final = await self._core_async_get_healthy_deployments(
- model=model,
- messages=[{"role": "user", "content": "files-api-fake-text"}],
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- parent_otel_span=parent_otel_span,
- )
-
- async def create_file_for_deployment(deployment: dict) -> OpenAIFileObject:
- from litellm.litellm_core_utils.core_helpers import safe_deep_copy
-
- kwargs_copy: Final = safe_deep_copy(kwargs)
- self._update_kwargs_with_deployment(
- deployment=deployment,
- kwargs=kwargs_copy,
- function_name="acreate_file",
- )
- data: Final = deployment["litellm_params"].copy()
- model_name: Final = data["model"]
-
- model_client: Final = self._get_async_openai_model_client(
- deployment=deployment,
- kwargs=kwargs_copy,
- )
- self.total_calls[model_name] += 1
-
- ## REPLACE MODEL IN FILE WITH SELECTED DEPLOYMENT ##
- # For DB/config deployments, use provider from deployment params
- custom_llm_provider = data.get("custom_llm_provider")
- stripped_model, inferred_custom_llm_provider, _, _ = get_llm_provider(
- model=data["model"],
- custom_llm_provider=custom_llm_provider,
- )
- # Preserve explicitly stored provider, fallback to inferred
- custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider
-
- ## REPLACE MODEL IN FILE WITH SELECTED DEPLOYMENT ##
- purpose: Final = cast(OpenAIFilesPurpose | None, kwargs.get("purpose"))
- file = cast(FileTypes | None, kwargs.get("file"))
- if not file or not purpose:
- raise Exception("file and file_purpose are required for create_file")
-
- replace_model_in_jsonl_bool: Final = should_replace_model_in_jsonl(
- purpose=purpose,
- passthrough=kwargs.get("passthrough") is True,
- )
- if replace_model_in_jsonl_bool:
- file = replace_model_in_jsonl(
- file_content=file,
- new_model_name=stripped_model,
- keep_line_numbers=custom_llm_provider == LlmProviders.VERTEX_AI.value,
- )
-
- kwargs_copy["file"] = file
- if custom_llm_provider == LlmProviders.LITELLM_PROXY.value:
- kwargs_copy["extra_body"] = MappingProxyType(
- {
- **(kwargs_copy.get("extra_body") or MappingProxyType({})),
- "target_model_names": stripped_model,
- }
- )
- if (
- "gcs_bucket_name" in data
- ): # TODO: Remove this once we have a better way to handle GCS bucket name: Problem is that we need to pass the gcs_bucket_name to the router for the create_file call but it doesn't show up there
- kwargs_copy.setdefault("litellm_metadata", {})["gcs_bucket_name"] = data["gcs_bucket_name"]
- async with self._deployment_slot(
- deployment=deployment, kwargs=kwargs_copy, parent_otel_span=parent_otel_span
- ):
- response = await litellm.acreate_file(
- **{
- **data,
- "custom_llm_provider": custom_llm_provider,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs_copy,
- }
- )
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.acreate_file(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
-
- return response
-
- tasks: Final = []
-
- if isinstance(healthy_deployments, dict):
- tasks.append(create_file_for_deployment(healthy_deployments))
- else:
- for deployment in healthy_deployments:
- tasks.append(create_file_for_deployment(deployment))
-
- responses: Final = await asyncio.gather(*tasks)
-
- if len(responses) == 0:
- raise Exception("No healthy deployments found.")
-
- model_file_id_mapping: Final = add_model_file_id_mappings(
- healthy_deployments=healthy_deployments, responses=responses
- )
- returned_response: Final = cast(OpenAIFileObject, responses[0])
- returned_response_hidden_params: Final = get_hidden_params(returned_response)
- if returned_response_hidden_params is not None:
- returned_response_hidden_params["model_file_id_mapping"] = model_file_id_mapping
- return returned_response
- except Exception as e:
- verbose_router_logger.exception(
- "litellm.acreate_file(model=%s, %s)\x1b[31m Exception %s\x1b[0m", model, kwargs, e
- )
- if model is not None:
- self.fail_calls[model] += 1
- raise e
-
- #### VECTOR STORES API ####
- async def avector_store_create(
- self,
- model: str | None,
- **kwargs,
- ):
- """
- Create a vector store for a specific model.
-
- Args:
- model: Model name from router config
- **kwargs: Vector store creation parameters
-
- Returns:
- VectorStoreCreateResponse
- """
- try:
- # If model is None, use the factory function approach (direct SDK call)
- if model is None:
- from litellm.vector_stores.main import acreate
-
- # Use the factory function to handle the call
- factory_fn: Final = self.factory_function(acreate, call_type="avector_store_create")
- return await factory_fn(**kwargs)
-
- from litellm.vector_stores import acreate as avector_store_create_sdk
-
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- deployment: Final = await self._core_async_get_available_deployment(
- model=model,
- messages=[{"role": "user", "content": "vector-store-api-fake-text"}],
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
- data: Final = deployment["litellm_params"].copy()
- model_name: Final = data["model"]
- self._update_kwargs_with_deployment(
- deployment=deployment,
- kwargs=kwargs,
- function_name="avector_store_create",
- )
-
- model_client: Final = self._get_async_openai_model_client(
- deployment=deployment,
- kwargs=kwargs,
- )
- self.total_calls[model_name] += 1
-
- # Get custom provider from deployment params
- custom_llm_provider = data.get("custom_llm_provider")
- _, inferred_custom_llm_provider, _, _ = get_llm_provider(
- model=data["model"],
- custom_llm_provider=custom_llm_provider,
- )
- custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider
-
- async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
- response = await avector_store_create_sdk(
- **{
- **data,
- "custom_llm_provider": custom_llm_provider,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs,
- }
- )
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.avector_store_create(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
-
- return response
- except Exception as e:
- verbose_router_logger.exception(
- "litellm.avector_store_create(model=%s)\x1b[31m Exception %s\x1b[0m", model, e
- )
- if model is not None:
- self.fail_calls[model] += 1
- raise e
-
- def _override_vector_store_methods_for_router(self):
- """
- Override factory-generated vector store methods with router-aware implementations.
- This is called after _initialize_vector_store_endpoints() to ensure our custom
- methods that handle deployment selection and credential injection are used instead
- of the generic factory-generated ones.
- """
- # Store references to the custom methods defined above
- # These methods handle proper routing through deployments
- # The methods are already defined as instance methods above
-
- async def acreate_batch(
- self,
- model: str,
- **kwargs,
- ) -> LiteLLMBatch:
- try:
- kwargs["model"] = model
- kwargs["original_function"] = self._acreate_batch
- metadata_variable_name: Final = get_router_metadata_variable_name(function_name="_acreate_batch")
- self._update_kwargs_before_fallbacks(
- model=model,
- kwargs=kwargs,
- metadata_variable_name=metadata_variable_name,
- )
- response: Final = await self.async_function_with_fallbacks(**kwargs)
-
- return response
- except Exception as e:
- asyncio.create_task(
- send_llm_exception_alert(
- ctx=self._core_ctx,
- request_kwargs=kwargs,
- error_traceback_str=traceback.format_exc(),
- original_exception=e,
- )
- )
- raise e
-
- async def _acreate_batch(
- self,
- model: str,
- **kwargs,
- ) -> LiteLLMBatch:
- try:
- verbose_router_logger.debug("Inside _acreate_batch()- model: %s", model)
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- with batch_create_routing():
- deployment: Final = await self._core_async_get_available_deployment(
- model=model,
- messages=[{"role": "user", "content": "files-api-fake-text"}],
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
-
- data: Final = deployment["litellm_params"].copy()
- model_name: Final = data["model"]
- self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name="_acreate_batch")
-
- model_client: Final = self._get_async_openai_model_client(
- deployment=deployment,
- kwargs=kwargs,
- )
- self.total_calls[model_name] += 1
-
- ## SET CUSTOM PROVIDER TO SELECTED DEPLOYMENT ##
- custom_llm_provider = data.get("custom_llm_provider")
- _, inferred_custom_llm_provider, _, _ = get_llm_provider(
- model=data["model"],
- custom_llm_provider=custom_llm_provider,
- )
- custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider
-
- async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
- response = await litellm.acreate_batch(
- **{
- **data,
- "custom_llm_provider": custom_llm_provider,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs,
- }
- )
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.acreate_batch(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
-
- return response
- except Exception as e:
- verbose_router_logger.exception(
- "litellm._acreate_batch(model=%s, %s)\x1b[31m Exception %s\x1b[0m", model, kwargs, e
- )
- if model is not None:
- self.fail_calls[model] += 1
- self._stamp_retry_skip_deployment_id(e, kwargs)
- raise e
-
- async def aretrieve_batch(
- self,
- model: str | None = None,
- **kwargs,
- ) -> LiteLLMBatch:
- """
- Iterate through all models in a model group to check for batch
-
- Future Improvement - cache the result.
- """
- try:
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- requested_model_group: Final = model
- metadata_variable_name: Final = get_router_metadata_variable_name(function_name="aretrieve_batch")
- if model is not None:
- filtered_model_list: (
- list[DeploymentTypedDict] | list[dict] | dict | None
- ) = await self._core_async_get_healthy_deployments(
- model=model,
- messages=[{"role": "user", "content": "retrieve-api-fake-text"}],
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- parent_otel_span=parent_otel_span,
- )
- else:
- filtered_model_list = self._core_get_model_list()
- if filtered_model_list is None:
- raise Exception("Router not yet initialized.")
-
- receieved_exceptions: Final = []
-
- async def try_retrieve_batch(model_name: DeploymentTypedDict):
- try:
- from litellm.litellm_core_utils.core_helpers import safe_deep_copy
-
- model: Final = model_name["litellm_params"].get("model")
- data: Final = model_name["litellm_params"].copy()
- custom_llm_provider = data.get("custom_llm_provider")
- if model is None:
- raise Exception(f"Model not found in litellm_params for deployment: {model_name}")
- # Update kwargs with the current model name or any other model-specific adjustments
- ## SET CUSTOM PROVIDER TO SELECTED DEPLOYMENT ##
- if not custom_llm_provider:
- _, custom_llm_provider, _, _ = get_llm_provider(model=model)
- new_kwargs: Final = safe_deep_copy(kwargs)
- self._update_kwargs_with_deployment(
- deployment=cast(dict, model_name),
- kwargs=new_kwargs,
- function_name="aretrieve_batch",
- )
- model_group: Final = requested_model_group or model_name["model_name"]
- if not new_kwargs[metadata_variable_name].get("model_group"):
- new_kwargs[metadata_variable_name]["model_group"] = model_group
- new_kwargs.pop("custom_llm_provider", None)
- data.pop("custom_llm_provider", None)
- return await litellm.aretrieve_batch(
- **{
- **data,
- "custom_llm_provider": custom_llm_provider,
- **new_kwargs,
- },
- )
- except Exception as e:
- import traceback
-
- traceback.print_exc()
- receieved_exceptions.append(e)
- return None
-
- # Check all models in parallel
- if (
- filtered_model_list is not None
- and isinstance(filtered_model_list, list)
- and len(filtered_model_list) > 0
- ):
- results = await asyncio.gather(
- *[try_retrieve_batch(cast(DeploymentTypedDict, model)) for model in filtered_model_list],
- return_exceptions=True,
- )
- elif filtered_model_list is not None and isinstance(filtered_model_list, dict):
- results = await try_retrieve_batch(cast(DeploymentTypedDict, filtered_model_list))
- else:
- raise Exception("No healthy deployments found.")
-
- # Check for successful responses and handle exceptions
- if results is not None:
- if isinstance(results, LiteLLMBatch):
- return results
- elif isinstance(results, list):
- for result in results:
- if isinstance(result, LiteLLMBatch):
- return result
-
- # If no valid Batch response was found, raise the first encountered exception
- if receieved_exceptions:
- raise receieved_exceptions[0] # Raising the first exception encountered
-
- # If no exceptions were encountered, raise a generic exception
- raise Exception(f"Unable to find batch in any model. Received errors - {receieved_exceptions}")
- except Exception as e:
- asyncio.create_task(
- send_llm_exception_alert(
- ctx=self._core_ctx,
- request_kwargs=kwargs,
- error_traceback_str=traceback.format_exc(),
- original_exception=e,
- )
- )
- raise e
-
- async def acancel_batch(
- self,
- model: str,
- **kwargs,
- ) -> LiteLLMBatch:
- """
- Cancel a batch through the router with proper model-to-provider mapping.
- """
- try:
- kwargs["model"] = model
- kwargs["original_function"] = self._acancel_batch
- metadata_variable_name: Final = get_router_metadata_variable_name(function_name="_acancel_batch")
- self._update_kwargs_before_fallbacks(
- model=model,
- kwargs=kwargs,
- metadata_variable_name=metadata_variable_name,
- )
- response: Final = await self.async_function_with_fallbacks(**kwargs)
-
- return response
- except Exception as e:
- asyncio.create_task(
- send_llm_exception_alert(
- ctx=self._core_ctx,
- request_kwargs=kwargs,
- error_traceback_str=traceback.format_exc(),
- original_exception=e,
- )
- )
- raise e
-
- async def _acancel_batch(
- self,
- model: str,
- **kwargs,
- ) -> LiteLLMBatch:
- try:
- verbose_router_logger.debug("Inside _acancel_batch()- model: %s", model)
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- deployment: Final = await self._core_async_get_available_deployment(
- model=model,
- messages=[{"role": "user", "content": "batch-api-fake-text"}],
- specific_deployment=kwargs.pop("specific_deployment", None),
- request_kwargs=kwargs,
- )
-
- selected_deployment_id: Final = (deployment.get("model_info") or {}).get("id")
- data: Final = deployment["litellm_params"].copy()
- resolved_credentials: Final = self._core_get_deployment_credentials_with_provider(
- model_id=selected_deployment_id or model
- )
- if resolved_credentials is not None:
- data.update(resolved_credentials)
- data.pop("litellm_credential_name", None)
- model_name: Final = data["model"]
- self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name="_acancel_batch")
-
- model_client: Final = self._get_async_openai_model_client(
- deployment=deployment,
- kwargs=kwargs,
- )
- self.total_calls[model_name] += 1
-
- ## SET CUSTOM PROVIDER TO SELECTED DEPLOYMENT ##
- custom_llm_provider = data.get("custom_llm_provider")
- _, inferred_custom_llm_provider, _, _ = get_llm_provider(
- model=data["model"],
- custom_llm_provider=custom_llm_provider,
- )
- custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider
-
- async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
- response = await litellm.acancel_batch(
- **{
- **data,
- "custom_llm_provider": custom_llm_provider,
- "caching": self._core_cache_responses,
- "client": model_client,
- **kwargs,
- }
- )
-
- self.success_calls[model_name] += 1
- verbose_router_logger.info("litellm.acancel_batch(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
-
- return response
- except Exception as e:
- verbose_router_logger.exception(
- "litellm._acancel_batch(model=%s, %s)\x1b[31m Exception %s\x1b[0m", model, kwargs, e
- )
- if model is not None:
- self.fail_calls[model] += 1
- self._stamp_retry_skip_deployment_id(e, kwargs)
- raise e
-
- async def alist_batches(
- self,
- model: str,
- **kwargs,
- ):
- """
- Return all the batches across all deployments of a model group.
- """
-
- filtered_model_list: Final = self._core_get_model_list(model_name=model)
- if filtered_model_list is None:
- raise Exception("Router not yet initialized.")
-
- async def try_retrieve_batch(model: DeploymentTypedDict):
- try:
- # Update kwargs with the current model name or any other model-specific adjustments
- return await litellm.alist_batches(**{**model["litellm_params"], **kwargs})
- except Exception:
- return None
-
- # Check all models in parallel
- results: Final = await asyncio.gather(*[try_retrieve_batch(model) for model in filtered_model_list])
-
- final_results: Final[dict] = {
- "object": "list",
- "data": [],
- "first_id": None,
- "last_id": None,
- "has_more": False,
- }
-
- for result in results:
- if result is not None:
- ## check batch id
- if final_results["first_id"] is None and hasattr(result, "first_id"):
- final_results["first_id"] = getattr(result, "first_id")
- final_results["last_id"] = getattr(result, "last_id")
- final_results["data"].extend(result.data)
-
- ## check 'has_more'
- if getattr(result, "has_more", False) is True:
- final_results["has_more"] = True
-
- return final_results
-
- #### PASSTHROUGH API ####
-
- async def _pass_through_moderation_endpoint_factory(
- self,
- original_function: Callable,
- custom_llm_provider: str | None = None,
- **kwargs,
- ):
- # update kwargs with model_group
- self._update_kwargs_before_fallbacks(
- model=kwargs.get("model", ""),
- kwargs=kwargs,
- )
- if kwargs.get("model") and self._core_get_model_list(model_name=kwargs["model"]):
- deployment: Final = await self._core_async_get_available_deployment(
- model=kwargs["model"],
- request_kwargs=kwargs,
- )
- kwargs["model"] = deployment["litellm_params"]["model"]
- data: Final = deployment["litellm_params"].copy()
- self._update_kwargs_with_deployment(
- deployment=deployment,
- kwargs=kwargs,
- )
- kwargs.update(data)
-
- return await original_function(**kwargs)
-
- def factory_function(
- self,
- original_function: Callable,
- call_type: Literal[
- "assistants",
- "moderation",
- "anthropic_messages",
- "aresponses",
- "acancel_responses",
- "acompact_responses",
- "responses",
- "aget_responses",
- "adelete_responses",
- "afile_delete",
- "afile_content",
- "_arealtime",
- "acreate_realtime_client_secret",
- "arealtime_calls",
- "acreate_realtime_transcription_session",
- "_aresponses_websocket",
- "acreate_fine_tuning_job",
- "acancel_fine_tuning_job",
- "alist_fine_tuning_jobs",
- "aretrieve_fine_tuning_job",
- "alist_files",
- "aimage_edit",
- "allm_passthrough_route",
- "alist_input_items",
- "agenerate_content",
- "generate_content",
- "agenerate_content_stream",
- "generate_content_stream",
- "avector_store_search",
- "avector_store_create",
- "avector_store_retrieve",
- "avector_store_list",
- "avector_store_update",
- "avector_store_delete",
- "avector_store_file_create",
- "avector_store_file_list",
- "avector_store_file_retrieve",
- "avector_store_file_content",
- "avector_store_file_update",
- "avector_store_file_delete",
- "vector_store_search",
- "vector_store_create",
- "vector_store_retrieve",
- "vector_store_list",
- "vector_store_update",
- "vector_store_delete",
- "vector_store_file_create",
- "vector_store_file_list",
- "vector_store_file_retrieve",
- "vector_store_file_content",
- "vector_store_file_update",
- "vector_store_file_delete",
- "aocr",
- "ocr",
- "asearch",
- "search",
- "adecisions",
- "decisions",
- "asystemone",
- "systemone",
- "aextraction",
- "extraction",
- "aadapter_generate_content",
- "avideo_generation",
- "video_generation",
- "avideo_list",
- "video_list",
- "avideo_status",
- "video_status",
- "avideo_content",
- "video_content",
- "avideo_remix",
- "video_remix",
- "avideo_create_character",
- "video_create_character",
- "avideo_get_character",
- "video_get_character",
- "avideo_edit",
- "video_edit",
- "avideo_extension",
- "video_extension",
- "acreate_container",
- "create_container",
- "alist_containers",
- "list_containers",
- "aretrieve_container",
- "retrieve_container",
- "adelete_container",
- "delete_container",
- "aupload_container_file",
- "upload_container_file",
- "alist_container_files",
- "list_container_files",
- "aretrieve_container_file",
- "retrieve_container_file",
- "adelete_container_file",
- "delete_container_file",
- "acreate_skill",
- "alist_skills",
- "aget_skill",
- "adelete_skill",
- "acreate_interaction",
- "create_interaction",
- "aget_interaction",
- "get_interaction",
- "adelete_interaction",
- "delete_interaction",
- "acancel_interaction",
- "cancel_interaction",
- "acreate_agent",
- "create_agent",
- "alist_agents",
- "list_agents",
- "aget_agent",
- "get_agent",
- "adelete_agent",
- "delete_agent",
- "alist_agent_versions",
- "list_agent_versions",
- ] = "assistants",
- ):
- """
- Creates appropriate wrapper functions for different API call types.
-
- Returns:
- - A synchronous function for synchronous call types
- - An asynchronous function for asynchronous call types
- """
- # Handle synchronous call types
- if call_type in (
- "responses",
- "generate_content",
- "generate_content_stream",
- "ocr",
- "search",
- "decisions",
- "systemone",
- "extraction",
- "video_generation",
- "video_list",
- "video_status",
- "video_content",
- "video_remix",
- "create_container",
- "list_containers",
- "retrieve_container",
- "delete_container",
- ):
-
- def sync_wrapper(
- custom_llm_provider: str | None = None,
- client: object | None = None,
- **kwargs,
- ):
- return self._generic_api_call_with_fallbacks(original_function=original_function, **kwargs)
-
- return sync_wrapper
-
- if call_type in (
- "vector_store_search",
- "vector_store_create",
- "vector_store_retrieve",
- "vector_store_list",
- "vector_store_update",
- "vector_store_delete",
- ):
-
- def vector_store_sync_wrapper(
- custom_llm_provider: str | None = None,
- client: object | None = None,
- **kwargs,
- ):
- provider_kwargs: Final = (
- MappingProxyType({**kwargs, "custom_llm_provider": custom_llm_provider})
- if custom_llm_provider and "custom_llm_provider" not in kwargs
- else MappingProxyType(kwargs)
- )
- search_kwargs: Final = (
- MappingProxyType(
- {
- **provider_kwargs,
- "_direct_vector_store_embedding_executor": RouterVectorStoreEmbeddingExecutor(
- router=self,
- metadata=self._vector_store_request_metadata(kwargs),
- ),
- }
- )
- if call_type == "vector_store_search"
- else provider_kwargs
- )
- if search_kwargs.get("model"):
- return self._generic_api_call_with_fallbacks(original_function=original_function, **search_kwargs)
- if call_type == "vector_store_search":
- return original_function(**MappingProxyType({**search_kwargs, "router": self}))
- return original_function(**search_kwargs)
-
- return vector_store_sync_wrapper
-
- if call_type in (
- "vector_store_file_create",
- "vector_store_file_list",
- "vector_store_file_retrieve",
- "vector_store_file_content",
- "vector_store_file_update",
- "vector_store_file_delete",
- ):
-
- def vector_store_file_sync_wrapper(
- custom_llm_provider: str | None = None,
- client: object | None = None,
- **kwargs,
- ):
- return original_function(
- custom_llm_provider=custom_llm_provider,
- client=client,
- **kwargs,
- )
-
- return vector_store_file_sync_wrapper
-
- if call_type in (
- "create_agent",
- "list_agents",
- "get_agent",
- "delete_agent",
- "list_agent_versions",
- ):
-
- def managed_agents_sync_wrapper(
- custom_llm_provider: str | None = None,
- client: object | None = None,
- **kwargs,
- ):
- if custom_llm_provider and "custom_llm_provider" not in kwargs:
- kwargs["custom_llm_provider"] = custom_llm_provider
- if "custom_llm_provider" not in kwargs:
- kwargs["custom_llm_provider"] = "gemini"
- return original_function(**kwargs)
-
- return managed_agents_sync_wrapper
-
- # Handle asynchronous call types
- async def async_wrapper(
- custom_llm_provider: str | None = None,
- client: AsyncOpenAI | None = None,
- **kwargs,
- ):
- if call_type == "assistants":
- return await self._pass_through_assistants_endpoint_factory(
- original_function=original_function,
- custom_llm_provider=custom_llm_provider,
- client=client,
- **kwargs,
- )
- elif call_type == "moderation":
- return await self._pass_through_moderation_endpoint_factory(
- original_function=original_function, **kwargs
- )
- elif call_type in ("asearch", "search"):
- return await self._asearch_with_fallbacks(
- original_function=original_function,
- **kwargs,
- )
- elif call_type in (
- "avector_store_file_create",
- "avector_store_file_list",
- "avector_store_file_retrieve",
- "avector_store_file_content",
- "avector_store_file_update",
- "avector_store_file_delete",
- ):
- return await self._init_vector_store_api_endpoints(
- original_function=original_function,
- custom_llm_provider=custom_llm_provider,
- **kwargs,
- )
- elif call_type == "aresponses":
- return await self._aresponses_with_streaming_fallbacks(
- original_function=original_function,
- **kwargs,
- )
- elif call_type in (
- "acreate_realtime_client_secret",
- "arealtime_calls",
- "acreate_realtime_transcription_session",
- ):
- return await self._ageneric_api_call_with_fallbacks(
- original_function=original_function,
- client=client,
- **kwargs,
- )
- elif call_type in (
- "anthropic_messages",
- "_arealtime",
- "_aresponses_websocket",
- "acreate_fine_tuning_job",
- "acancel_fine_tuning_job",
- "alist_fine_tuning_jobs",
- "aretrieve_fine_tuning_job",
- "alist_files",
- "aimage_edit",
- "agenerate_content",
- "agenerate_content_stream",
- "aocr",
- "ocr",
- "adecisions",
- "asystemone",
- "aextraction",
- "avideo_generation",
- "avideo_list",
- "avideo_status",
- "avideo_content",
- "avideo_remix",
- "avideo_create_character",
- "avideo_get_character",
- "avideo_edit",
- "avideo_extension",
- "acreate_skill",
- "alist_skills",
- "aget_skill",
- "adelete_skill",
- ):
- return await self._dispatch_generic_call_type(
- call_type=call_type,
- original_function=original_function,
- **kwargs,
- )
- elif call_type in (
- "acreate_container",
- "alist_containers",
- "aretrieve_container",
- "adelete_container",
- "aupload_container_file",
- "alist_container_files",
- "aretrieve_container_file",
- "adelete_container_file",
- "aretrieve_container_file_content",
- ):
- return await self._init_containers_api_endpoints(
- original_function=original_function,
- custom_llm_provider=custom_llm_provider,
- **kwargs,
- )
- elif call_type == "allm_passthrough_route":
- if client:
- kwargs["client"] = client
- return await self._ageneric_api_call_with_fallbacks(
- original_function=original_function,
- passthrough_on_no_deployment=True,
- **kwargs,
- )
- elif call_type in (
- "aget_responses",
- "acancel_responses",
- "acompact_responses",
- "adelete_responses",
- "alist_input_items",
- ):
- return await self._init_responses_api_endpoints(
- original_function=original_function,
- **kwargs,
- )
- elif call_type in (
- "avector_store_search",
- "avector_store_create",
- "avector_store_retrieve",
- "avector_store_list",
- "avector_store_update",
- "avector_store_delete",
- ):
- vector_store_kwargs: Final = (
- {
- **kwargs,
- "_direct_vector_store_embedding_executor": RouterVectorStoreEmbeddingExecutor(
- router=self,
- metadata=self._vector_store_request_metadata(kwargs),
- ),
- }
- if call_type == "avector_store_search"
- else kwargs
- )
- return await self._init_vector_store_api_endpoints(
- original_function=original_function,
- custom_llm_provider=custom_llm_provider,
- call_type=call_type,
- **vector_store_kwargs,
- )
- elif call_type in ("afile_delete", "afile_content"):
- return await self._ageneric_api_call_with_fallbacks(
- original_function=original_function,
- custom_llm_provider=custom_llm_provider,
- client=client,
- **kwargs,
- )
- elif call_type in (
- "acreate_interaction",
- "create_interaction",
- "aget_interaction",
- "adelete_interaction",
- "acancel_interaction",
- ):
- return await self._init_interactions_api_endpoints(
- original_function=original_function,
- custom_llm_provider=custom_llm_provider,
- **kwargs,
- )
- elif call_type in (
- "acreate_agent",
- "alist_agents",
- "aget_agent",
- "adelete_agent",
- "alist_agent_versions",
- ):
- return await self._init_managed_agents_api_endpoints(
- original_function=original_function,
- custom_llm_provider=custom_llm_provider,
- **kwargs,
- )
-
- return async_wrapper
-
- @staticmethod
- def _vector_store_request_metadata(kwargs: Mapping[str, object]) -> Mapping[str, object]:
- return vector_store_request_metadata(kwargs)
-
- async def _init_vector_store_api_endpoints(
- self,
- original_function: Callable,
- custom_llm_provider: str | None = None,
- call_type: str | None = None,
- **kwargs,
- ):
- """
- Initialize the Vector Store API endpoints on the router.
-
- If a model is provided in kwargs, use model-based routing to get
- the deployment credentials. Otherwise, call the original function directly.
- """
- if custom_llm_provider and "custom_llm_provider" not in kwargs:
- kwargs["custom_llm_provider"] = custom_llm_provider
-
- # If model is provided, use generic API call with fallbacks for proper routing
- if kwargs.get("model"):
- return await self._ageneric_api_call_with_fallbacks(
- original_function=original_function,
- **kwargs,
- )
-
- # For search, pass the router so provider transforms can resolve
- # router-managed embedding models (e.g. S3 Vectors query embeddings).
- # The merge also overrides any client-supplied `router` key.
- if call_type == "avector_store_search":
- search_kwargs: Final = MappingProxyType({**kwargs, "router": self})
- return await original_function(**search_kwargs)
-
- # Otherwise, call the original function directly
- return await original_function(**kwargs)
-
- async def _init_containers_api_endpoints(
- self,
- original_function: Callable,
- custom_llm_provider: str | None = None,
- **kwargs,
- ):
- """
- Initialize the Containers API endpoints on the router.
-
- LiteLLM-managed container IDs (``cntr_...``) encode ``model_id`` and provider
- metadata. When present, decode the ID, replace ``container_id`` with the
- upstream value, and route through ``_ageneric_api_call_with_fallbacks`` so
- deployment credentials (e.g. regional ``api_base`` for Azure) match
- :meth:`_init_responses_api_endpoints`. Create/list calls carry no container ID, so
- they route through the deployment named by ``model`` when the caller passes one,
- falling back to the direct call when no deployment matches. Otherwise call the
- handler directly with global provider credentials.
- """
- if custom_llm_provider and "custom_llm_provider" not in kwargs:
- kwargs["custom_llm_provider"] = custom_llm_provider
-
- from litellm.responses.utils import ResponsesAPIRequestUtils
-
- container_id: Final = kwargs.get("container_id")
- _forwarded_model_id: Final = kwargs.get("model_id")
- if isinstance(container_id, str):
- decoded: Final = ResponsesAPIRequestUtils.decode_container_id(container_id)
- original_id: Final = decoded.get("response_id", container_id)
- if original_id != container_id:
- kwargs["container_id"] = original_id
- decoded_provider: Final = decoded.get("custom_llm_provider")
- if decoded_provider and kwargs.get("custom_llm_provider") == "openai":
- kwargs["custom_llm_provider"] = decoded_provider
- # Fall back to the model_id forwarded by the proxy when the container_id
- # is a native upstream ID (e.g. Azure hex cntr_) that carries no LiteLLM
- # routing payload, so deployment credentials (api_base, api_key) are applied.
- model_id: Final = decoded.get("model_id") or (
- _forwarded_model_id.strip()
- if isinstance(_forwarded_model_id, str) and _forwarded_model_id.strip()
- else None
- )
- if model_id:
- kwargs["model"] = model_id
- return await self._ageneric_api_call_with_fallbacks(
- original_function=original_function,
- **kwargs,
- )
-
- requested_model: Final = kwargs.get("model")
- if isinstance(requested_model, str) and requested_model.strip():
- return await self._ageneric_api_call_with_fallbacks(
- original_function=original_function,
- passthrough_on_no_deployment=True,
- **kwargs,
- )
-
- return await original_function(**kwargs)
-
- async def _init_responses_api_endpoints(
- self,
- original_function: Callable,
- **kwargs,
- ):
- """
- Initialize the Responses API endpoints on the router.
-
- GET, DELETE, CANCEL Responses API Requests encode the model_id in the response_id, this function decodes the response_id and sets the model to the model_id.
- """
- from litellm.responses.utils import ResponsesAPIRequestUtils
-
- model_id: Final = ResponsesAPIRequestUtils.get_model_id_from_response_id(kwargs.get("response_id"))
- if model_id is not None:
- kwargs["model"] = model_id
- return await self._ageneric_api_call_with_fallbacks(
- original_function=original_function,
- **kwargs,
- )
-
- async def _init_interactions_api_endpoints(
- self,
- original_function: Callable,
- custom_llm_provider: str | None = None,
- **kwargs,
- ):
- """
- Initialize the Interactions API endpoints on the router.
-
- GET, DELETE, CANCEL Interactions API Requests don't need model-based routing,
- so we call the original function directly with the custom_llm_provider.
- """
- if custom_llm_provider and "custom_llm_provider" not in kwargs:
- kwargs["custom_llm_provider"] = custom_llm_provider
- # Default to gemini for interactions API
- if "custom_llm_provider" not in kwargs:
- kwargs["custom_llm_provider"] = "gemini"
- # If the proxy accidentally passed agent name as model, clear it
- if kwargs.get("agent") and kwargs.get("model") == kwargs.get("agent"):
- kwargs["model"] = None
- # Model-based interactions use deployment routing + fallbacks; agent-only calls
- # must not enter model-group lookup (agent name is not a LiteLLM deployment).
- if kwargs.get("model"):
- return await self._ageneric_api_call_with_fallbacks(
- original_function=original_function,
- **kwargs,
- )
- return await original_function(**kwargs)
-
- async def _init_managed_agents_api_endpoints(
- self,
- original_function: Callable,
- custom_llm_provider: str | None = None,
- **kwargs,
- ):
- """
- Initialize the Managed Agents API endpoints on the router (v1beta/agents).
-
- CRUD operations for Gemini managed agents don't need model-based routing,
- so we call the original function directly with the custom_llm_provider.
- """
- if custom_llm_provider and "custom_llm_provider" not in kwargs:
- kwargs["custom_llm_provider"] = custom_llm_provider
- if "custom_llm_provider" not in kwargs:
- kwargs["custom_llm_provider"] = "gemini"
- return await original_function(**kwargs)
-
- async def _pass_through_assistants_endpoint_factory(
- self,
- original_function: Callable,
- custom_llm_provider: str | None = None,
- client: AsyncOpenAI | None = None,
- **kwargs,
- ):
- """Internal helper function to pass through the assistants endpoint"""
- if custom_llm_provider is None:
- if self.assistants_config is not None:
- custom_llm_provider = self.assistants_config["custom_llm_provider"]
- kwargs.update(self.assistants_config["litellm_params"])
- else:
- raise Exception(
- "'custom_llm_provider' must be set. Either via:\n `Router(assistants_config={'custom_llm_provider': ..})` \nor\n `router.arun_thread(custom_llm_provider=..)`"
- )
- return await original_function(custom_llm_provider=custom_llm_provider, client=client, **kwargs)
-
- #### [END] ASSISTANTS API ####
-
- async def _maybe_run_weighted_failover(
- self,
- exception: Exception,
- original_model_group: str,
- all_deployments: Sequence[DeploymentTypedDict],
- args: tuple,
- kwargs: dict,
- input_kwargs: dict,
- ) -> Any | None:
- """Same-model-group retry after a failed deployment; returns None if not applicable."""
- strategy, _ = self._get_routing_context(original_model_group, kwargs)
- if strategy != "simple-shuffle":
- return None
-
- failed_id: Final[str | None] = getattr(exception, "failed_deployment_id", None)
- if not failed_id:
- return None
-
- metadata_variable_name: Final = self._get_metadata_variable_name_from_kwargs(kwargs)
- meta = kwargs.get(metadata_variable_name)
- if meta is None:
- meta = {}
- kwargs[metadata_variable_name] = meta
- if not isinstance(meta, dict):
- return None
- prev_excluded: Final = set(meta.get("_failover_excluded_ids") or [])
- excluded: Final = prev_excluded | {failed_id}
-
- all_ids: Final = {
- (d.get("model_info") or {}).get("id")
- for d in all_deployments
- if (d.get("model_info") or {}).get("id") is not None
- }
- # Only consider deployments that are currently healthy (not in cooldown).
- # Using all_ids here would cause a wasteful run_async_fallback invocation
- # that fails with RouterRateLimitError whenever the "remaining" entries
- # are all in cooldown — the inner async_get_healthy_deployments call
- # would find an empty list and raise immediately.
- cooldown_ids = set(await async_get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=None))
- remaining: Final = (all_ids - cooldown_ids) - excluded
- if not remaining:
- return None
-
- verbose_router_logger.debug(
- "Weighted failover: exclude=%r, remaining=%s for model_group=%r",
- excluded,
- len(remaining),
- original_model_group,
- )
-
- meta["_failover_excluded_ids"] = list(excluded)
-
- entry: Final = {
- "model": original_model_group,
- "_excluded_deployment_ids": list(excluded),
- }
- # Build a local copy so the weighted-failover keys do not leak back to
- # the caller's shared kwargs dict (any downstream fallback path reads
- # the same dict and must not inherit our `_excluded_deployment_ids`
- # entry).
- failover_kwargs: Final = {
- **input_kwargs,
- "fallback_model_group": [entry],
- "original_model_group": original_model_group,
- }
- try:
- return await run_async_fallback(*args, **failover_kwargs)
- except (openai.APIError, RouterRateLimitError, RouterRateLimitErrorBasic):
- # Expected model-level failure on the retried deployment. All
- # litellm provider errors derive from openai.APIError; if every
- # remaining deployment in the group is in cooldown the router
- # raises RouterRateLimitError (a ValueError, not an APIError).
- # In either case defer to the regular fallback path. Programming
- # errors (AttributeError, KeyError, TypeError, etc.) intentionally
- # propagate so they remain visible.
- return None
-
- async def async_function_with_fallbacks_common_utils(
- self,
- e: Exception,
- disable_fallbacks: bool | None,
- fallbacks: list | None,
- context_window_fallbacks: list | None,
- content_policy_fallbacks: list | None,
- model_group: str | None,
- args: tuple,
- kwargs: dict,
- include_fallback_errors: bool = False,
- ):
- """
- Common utilities for async_function_with_fallbacks
- """
- if verbose_router_logger.isEnabledFor(logging.DEBUG):
- verbose_router_logger.debug("Traceback%s", redact_string(traceback.format_exc()))
- original_exception: Final = e
- fallback_model_group = None
- original_model_group: Final[str | None] = kwargs.get("model")
- fallback_walk: Final = attempted_fallback_targets_from(kwargs) # pyright: ignore[reportUnknownArgumentType] # keep legacy kwargs untyped
- # A pre-routing hook (complexity / auto / adaptive / quality routers) picks a tier
- # behind the router name, and fallbacks are configured per tier, not per router.
- lookup_groups: Final[tuple[str, ...]] = fallback_lookup_groups(kwargs, model_group)
- fallback_failure_exception_str = ""
- no_fallback_group_explained = False
- hop_depth: Final = kwargs.get("fallback_depth")
- nested_fallback_hop: Final = isinstance(hop_depth, int) and hop_depth > 0
-
- unsupported_params_without_fallback: Final = is_bad_request_without_fallback(
- e, self.fallback_on_unsupported_params
- )
- if (
- disable_fallbacks is True
- or original_model_group is None
- or is_guardrail_intervention(e)
- or unsupported_params_without_fallback
- ):
- raise e
-
- input_kwargs: Final = {
- "litellm_routing_ctx": self._core_ctx,
- "original_exception": original_exception,
- **kwargs,
- "attempted_targets": fallback_walk,
- }
-
- if "max_fallbacks" not in input_kwargs:
- input_kwargs["max_fallbacks"] = self.max_fallbacks
- if "fallback_depth" not in input_kwargs:
- input_kwargs["fallback_depth"] = 0
- if include_fallback_errors:
- input_kwargs["include_fallback_errors"] = True
-
- # ORDER-BASED FALLBACKS: prepend higher order levels to the fallback list
- # Skip for error types that have their own dedicated fallback handlers
- _skip_order_fallback: Final = isinstance(
- e,
- (litellm.ContextWindowExceededError, litellm.ContentPolicyViolationError),
- )
- _request_team_id: Final[str | None] = (kwargs.get("metadata", {}) or {}).get("user_api_key_team_id")
- # Use wildcard-aware lookup so order-based fallback also works for model
- # groups resolved via pattern routing (e.g. `openai/*` -> `openai/gpt-4.1-mini`).
- order_model_group: Final = get_pre_routing_selection(kwargs) or original_model_group
- all_deployments: Final = self._core_get_model_list(model_name=order_model_group, team_id=_request_team_id) or ()
- _order_set: Final[set] = {
- litellm.utils.get_deployment_order(d)
- for d in all_deployments
- if litellm.utils.get_deployment_order(d) is not None
- }
- order_values: Final[list] = sorted(_order_set)
- if len(order_values) > 1 and not _skip_order_fallback:
- # Determine which order levels have already been tried
- current_target: Final = kwargs.get("_target_order")
- skip_up_to: Final = current_target if current_target is not None else order_values[0]
- # Build order-based fallback entries (skip already-tried levels)
- order_fallback_entries: Final[list] = [
- {"model": order_model_group, "_target_order": o} for o in order_values if o > skip_up_to
- ]
- # Get external fallbacks — handle both standard and non-standard formats
- external_fallback_group: list | None = None
- if fallbacks is not None and lookup_groups:
- if check_non_standard_fallback_format(fallbacks=fallbacks):
- # Non-standard formats (e.g. ["claude-3-haiku"] or
- # [{"model": "...", "messages": [...]}]) are passed through directly
- external_fallback_group = fallbacks
- else:
- external_fallback_group, generic_idx = get_fallback_model_group_for_lookup_groups(
- fallbacks=fallbacks,
- lookup_groups=lookup_groups,
- )
- if external_fallback_group is None and generic_idx is not None:
- external_fallback_group = fallbacks[generic_idx]["*"]
-
- # Combined list: order fallbacks first, then external
- combined_fallbacks: Final = order_fallback_entries + (external_fallback_group or [])
-
- if combined_fallbacks:
- input_kwargs.update(
- {
- "fallback_model_group": combined_fallbacks,
- "original_model_group": original_model_group,
- }
- )
- response = await run_async_fallback(
- *args,
- **input_kwargs,
- )
- return response
-
- # Weighted intra-group failover (simple-shuffle only); see _maybe_run_weighted_failover.
- if self.enable_weighted_failover and not _skip_order_fallback and original_model_group is not None:
- response = await self._maybe_run_weighted_failover(
- exception=e,
- original_model_group=original_model_group,
- all_deployments=all_deployments,
- args=args,
- kwargs=kwargs,
- input_kwargs=input_kwargs,
- )
- if response is not None:
- return response
-
- try:
- verbose_router_logger.info("Trying to fallback b/w models")
-
- # check if client-side fallbacks are used (e.g. fallbacks = ["gpt-3.5-turbo", "claude-3-haiku"] or fallbacks=[{"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "Hey, how's it going?"}]}]
- is_non_standard_fallback_format: Final = check_non_standard_fallback_format(fallbacks=fallbacks)
-
- if is_non_standard_fallback_format:
- input_kwargs.update(
- {
- "fallback_model_group": fallbacks,
- "original_model_group": original_model_group,
- }
- )
-
- response = await run_async_fallback(
- *args,
- **input_kwargs,
- )
-
- return response
-
- if isinstance(e, litellm.ContextWindowExceededError):
- if context_window_fallbacks is not None:
- context_window_fallback_model_group: Final[list[str] | None] = (
- self._get_fallback_model_group_for_lookup_groups(
- fallbacks=context_window_fallbacks,
- lookup_groups=lookup_groups,
- )
- )
- if context_window_fallback_model_group is None:
- raise original_exception
-
- input_kwargs.update(
- {
- "fallback_model_group": context_window_fallback_model_group,
- "original_model_group": original_model_group,
- }
- )
-
- response = await run_async_fallback(
- *args,
- **input_kwargs,
- )
- return response
-
- else:
- error_message = f"model={model_group}. context_window_fallbacks={mask_sensitive_structure(context_window_fallbacks)}. fallbacks={mask_sensitive_structure(fallbacks)}.\n\nSet 'context_window_fallback' - https://docs.litellm.ai/docs/routing#fallbacks"
- verbose_router_logger.info(
- msg=f"Got 'ContextWindowExceededError'. No context_window_fallback set. Defaulting \
- to fallbacks, if available.{error_message}"
- )
-
- if litellm.expose_router_debug_in_errors:
- e.message += f"\n{error_message}"
- elif isinstance(e, litellm.ContentPolicyViolationError):
- if content_policy_fallbacks is not None:
- content_policy_fallback_model_group: Final[list[str] | None] = (
- self._get_fallback_model_group_for_lookup_groups(
- fallbacks=content_policy_fallbacks,
- lookup_groups=lookup_groups,
- )
- )
- if content_policy_fallback_model_group is None:
- raise original_exception
-
- input_kwargs.update(
- {
- "fallback_model_group": content_policy_fallback_model_group,
- "original_model_group": original_model_group,
- }
- )
-
- response = await run_async_fallback(
- *args,
- **input_kwargs,
- )
- return response
- else:
- error_message = format_content_policy_fallback_hint(
- model_group, content_policy_fallbacks, fallbacks
- )
- verbose_router_logger.info(
- msg=f"Got 'ContentPolicyViolationError'. No content_policy_fallback set. Defaulting \
- to fallbacks, if available.{error_message}"
- )
-
- if litellm.expose_router_debug_in_errors:
- e.message += f"\n{error_message}"
- if fallbacks is not None and lookup_groups:
- verbose_router_logger.debug("inside model fallbacks: %s", mask_sensitive_structure(fallbacks))
- (
- fallback_model_group,
- generic_fallback_idx,
- ) = get_fallback_model_group_for_lookup_groups(
- fallbacks=fallbacks, # if fallbacks = [{"gpt-3.5-turbo": ["claude-3-haiku"]}]
- lookup_groups=lookup_groups,
- )
- ## if none, check for generic fallback
- if fallback_model_group is None and generic_fallback_idx is not None:
- fallback_model_group = fallbacks[generic_fallback_idx]["*"]
-
- if fallback_model_group is None:
- masked_fallbacks: Final = mask_sensitive_structure(fallbacks)
- verbose_router_logger.info(
- "No fallback model group found for lookup_groups=%s. Fallbacks=%s",
- " -> ".join(lookup_groups),
- masked_fallbacks,
- )
- if (
- hasattr(original_exception, "message")
- and litellm.expose_router_debug_in_errors
- and not nested_fallback_hop
- ):
- original_exception.message += format_no_fallback_group_message(lookup_groups, fallbacks)
- no_fallback_group_explained = True
- raise original_exception
-
- input_kwargs.update(
- {
- "fallback_model_group": fallback_model_group,
- "original_model_group": original_model_group,
- }
- )
-
- response = await run_async_fallback(
- *args,
- **input_kwargs,
- )
-
- return response
- except Exception as new_exception:
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- fallback_failure_exception_str = truncate_fallback_error_detail(redact_string(str(new_exception)))
- cooldown_info: Final = await async_get_cooldown_deployments_with_debug_info(
- ctx=self._core_ctx,
- parent_otel_span=parent_otel_span,
- )
- verbose_router_logger.error(
- "litellm.router.py::async_function_with_fallbacks() - "
- "Error occurred while trying to do fallbacks - %s\n"
- "Debug Information:\nCooldown Deployments=%s",
- fallback_failure_exception_str,
- cooldown_info,
- )
-
- attempted_fallback_group: Final = input_kwargs.get("fallback_model_group")
- if (
- hasattr(original_exception, "message")
- and litellm.expose_router_debug_in_errors
- and not no_fallback_group_explained
- and not nested_fallback_hop
- ):
- original_exception.message += format_fallback_outcome_message(
- model_group,
- attempted_fallback_group,
- fallback_failure_exception_str,
- skipped=fallback_walk.skipped,
- )
-
- raise original_exception
-
- @tracer.wrap()
- async def async_function_with_fallbacks(self, *args, **kwargs):
- """
- Try calling the function_with_retries
- If it fails after num_retries, fall back to another model group
- """
- model_group: Final[str | None] = kwargs.get("model")
- compaction_surface: Final = surface_for_call(
- getattr(kwargs.get("original_generic_function") or kwargs.get("original_function"), "__name__", "")
- )
- if compaction_surface is not None:
- kwargs["_context_compaction_state"] = initialize_compaction_state(kwargs, compaction_surface)
- clear_pre_routing_selection(kwargs) # pyright: ignore[reportUnknownArgumentType] # **kwargs is untyped at this boundary
- if not isinstance(kwargs.get("attempted_targets"), AttemptedFallbackTargets):
- _fallback_metadata_key: Final = get_router_metadata_variable_name(
- function_name=getattr(kwargs.get("original_function"), "__name__", None)
- )
- _sibling_metadata_key: Final = (
- "metadata" if _fallback_metadata_key == "litellm_metadata" else "litellm_metadata"
- )
- if isinstance(_sibling_metadata := kwargs.get(_sibling_metadata_key), dict):
- # In place, like every other router bucket write: downstream resolves the bucket by
- # key presence, so rebinding kwargs to a copy detaches the proxy's request_data write-backs
- _sibling_metadata.pop("attempted_fallbacks", None)
- _sibling_metadata.pop("original_model_group", None)
- if isinstance(_fallback_metadata := kwargs.get(_fallback_metadata_key), dict):
- _fallback_metadata["attempted_fallbacks"] = 0
- if model_group is not None:
- _fallback_metadata["original_model_group"] = model_group
- include_fallback_errors: Final = kwargs.get("include_fallback_errors", False) is True
- disable_fallbacks: Final[bool | None] = kwargs.pop("disable_fallbacks", False)
- record_disable_fallbacks(kwargs, disable_fallbacks is True)
- fallbacks: Final[list | None] = kwargs.get("fallbacks", self._core_fallbacks)
- context_window_fallbacks: list | None = kwargs.get(
- "context_window_fallbacks", self._core_context_window_fallbacks
- )
- content_policy_fallbacks: list | None = kwargs.get(
- "content_policy_fallbacks", self._core_content_policy_fallbacks
- )
-
- mock_timeout: Final = kwargs.pop("mock_timeout", None)
-
- try:
- self._handle_mock_testing_fallbacks(
- kwargs=kwargs,
- model_group=model_group,
- fallbacks=fallbacks,
- context_window_fallbacks=context_window_fallbacks,
- content_policy_fallbacks=content_policy_fallbacks,
- )
-
- if mock_timeout is not None:
- response = await self.async_function_with_retries(*args, **kwargs, mock_timeout=mock_timeout)
- else:
- response = await self.async_function_with_retries(*args, **kwargs)
- if verbose_router_logger.isEnabledFor(logging.DEBUG):
- verbose_router_logger.debug("Async Response: %s", response)
- response = add_fallback_headers_to_response(
- response=response,
- attempted_fallbacks=0,
- )
- return response
- except Exception as e:
- return await self.async_function_with_fallbacks_common_utils(
- e,
- disable_fallbacks,
- fallbacks,
- context_window_fallbacks,
- content_policy_fallbacks,
- model_group,
- args,
- kwargs,
- include_fallback_errors=include_fallback_errors,
- )
-
- def _handle_mock_testing_fallbacks(
- self,
- kwargs: dict,
- model_group: str | None = None,
- fallbacks: list | None = None,
- context_window_fallbacks: list | None = None,
- content_policy_fallbacks: list | None = None,
- ):
- """
- Helper function to raise a litellm Error for mock testing purposes.
-
- Raises:
- litellm.InternalServerError: when `mock_testing_fallbacks=True` passed in request params
- litellm.ContextWindowExceededError: when `mock_testing_context_fallbacks=True` passed in request params
- litellm.ContentPolicyViolationError: when `mock_testing_content_policy_fallbacks=True` passed in request params
- """
- mock_testing_params: Final = MockRouterTestingParams.from_kwargs(kwargs)
- if (
- mock_testing_params.mock_testing_fallbacks is not None
- and mock_testing_params.mock_testing_fallbacks is True
- ):
- raise litellm.InternalServerError(
- model=model_group,
- llm_provider="",
- message=f"This is a mock exception for model={model_group}, to trigger a fallback. Fallbacks={fallbacks}",
- )
- elif (
- mock_testing_params.mock_testing_context_fallbacks is not None
- and mock_testing_params.mock_testing_context_fallbacks is True
- ):
- raise litellm.ContextWindowExceededError(
- model=model_group,
- llm_provider="",
- message=f"This is a mock exception for model={model_group}, to trigger a fallback. \
- Context_Window_Fallbacks={context_window_fallbacks}",
- )
- elif (
- mock_testing_params.mock_testing_content_policy_fallbacks is not None
- and mock_testing_params.mock_testing_content_policy_fallbacks is True
- ):
- raise litellm.ContentPolicyViolationError(
- model=model_group,
- llm_provider="",
- message=f"This is a mock exception for model={model_group}, to trigger a fallback. \
- Context_Policy_Fallbacks={content_policy_fallbacks}",
- )
-
- @staticmethod
- def _deployment_ids_to_skip_on_retry(exception: Exception, already_skipped: object) -> tuple[str, ...]:
- failed_deployment_id: Final[str | None] = getattr(exception, "retry_skip_deployment_id", None) or getattr(
- exception, "failed_deployment_id", None
- )
- status_code: Final = getattr(exception, "status_code", None)
- if not failed_deployment_id or not isinstance(status_code, int):
- return ()
- if litellm.should_retry(status_code):
- return ()
- already_skipped_ids: Final = as_retry_skipped_deployment_ids(already_skipped)
- skipped: Final = tuple(sorted(frozenset((*already_skipped_ids, failed_deployment_id))))
- verbose_router_logger.debug(
- "Retry skips deployments that already answered %s to this request: %s", status_code, skipped
- )
- return skipped
-
- @tracer.wrap()
- async def async_function_with_retries(self, *args, **kwargs):
- verbose_router_logger.debug("Inside async function with retries.")
- original_function: Final = kwargs.pop("original_function")
- fallbacks: Final = kwargs.pop("fallbacks", self._core_fallbacks)
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
- context_window_fallbacks: Final = kwargs.pop("context_window_fallbacks", self._core_context_window_fallbacks)
- content_policy_fallbacks: Final = kwargs.pop("content_policy_fallbacks", self._core_content_policy_fallbacks)
- # Support per-request model_group_retry_policy override (from key/team settings)
- model_group_retry_policy: Final = kwargs.pop("model_group_retry_policy", self.model_group_retry_policy)
- model_group: Final[str | None] = kwargs.get("model")
- request_num_retries: Final[int | None] = kwargs.pop("num_retries", None)
- num_retries = request_num_retries
- if num_retries is None:
- # Fall back to the router setting (then 0) so the comparisons below never
- # hit `None > int`, which would mask the real upstream error with a TypeError.
- num_retries = self.num_retries if self.num_retries is not None else 0
-
- ## ADD MODEL GROUP SIZE TO METADATA - used for model_group_rate_limit_error tracking
- _metadata: Final[dict] = kwargs.get("litellm_metadata", kwargs.get("metadata")) or {}
- if "model_group" in _metadata and isinstance(_metadata["model_group"], str):
- model_list: Final = self._core_get_model_list(model_name=_metadata["model_group"])
- if model_list is not None:
- _metadata.update({"model_group_size": len(model_list)})
-
- verbose_router_logger.debug(
- "async function w/ retries: original_function - %s, num_retries - %s", original_function, num_retries
- )
- ## ADD RETRY TRACKING TO METADATA - used for spend logs retry tracking
- _metadata["attempted_retries"] = 0
- _metadata["max_retries"] = num_retries # Updated after overrides in exception handler
- try:
- self._handle_mock_testing_rate_limit_error(model_group=model_group, kwargs=kwargs)
- # if the function call is successful, no exception will be raised and we'll break out of the loop
- response = await self.make_call(original_function, *args, **kwargs)
- response = add_retry_headers_to_response(response=response, attempted_retries=0, max_retries=None)
- return response
- except Exception as e:
- if is_guardrail_intervention(e):
- raise
- current_attempt = None
- original_exception = e
- deployment_num_retries: Final = getattr(e, "num_retries", None)
-
- if (
- request_num_retries is None
- and deployment_num_retries is not None
- and isinstance(deployment_num_retries, int)
- ):
- num_retries = deployment_num_retries
- """
- Retry Logic
- """
- (
- _healthy_deployments,
- _all_deployments,
- ) = await self._async_get_healthy_deployments(
- model=kwargs.get("model") or "",
- parent_otel_span=parent_otel_span,
- request_kwargs=share_holder_request_kwargs(
- cast(Mapping[str, object], kwargs), # cast-ok: kwargs keys are param names
- self._ptu_share_teams(),
- ),
- )
-
- # Check retry policy FIRST, before should_retry_this_error
- # This allows retry policies to override the healthy deployments check
- _retry_policy_applies = False
- if request_num_retries != 0 and (self.retry_policy is not None or model_group_retry_policy is not None):
- # get num_retries from retry policy
- # Use the model_group captured at the start of the function, or get it from metadata
- # kwargs.get("model") at this point is the deployment model, not the model_group
- _model_group_for_retry_policy = model_group or _metadata.get("model_group") or kwargs.get("model")
- # Use per-request model_group_retry_policy if provided, otherwise use self
- _retry_policy_retries: Final = _get_num_retries_from_retry_policy(
- exception=original_exception,
- model_group=_model_group_for_retry_policy,
- model_group_retry_policy=model_group_retry_policy,
- retry_policy=self.retry_policy,
- )
- if _retry_policy_retries is not None:
- num_retries = _retry_policy_retries
- _retry_policy_applies = True
-
- # raises an exception if this error should not be retries
- # Skip this check if retry policy applies (retry policy takes precedence)
- if not _retry_policy_applies:
- self.should_retry_this_error(
- error=e,
- healthy_deployments=_healthy_deployments,
- all_deployments=_all_deployments,
- context_window_fallbacks=context_window_fallbacks,
- regular_fallbacks=fallbacks,
- content_policy_fallbacks=content_policy_fallbacks,
- )
- # Update max_retries after overrides (deployment_num_retries / retry_policy)
- _metadata["max_retries"] = num_retries
-
- ## LOGGING
- if num_retries > 0:
- kwargs = self._core_log_retry(kwargs=kwargs, e=original_exception)
- first_skipped_ids: Final = self._deployment_ids_to_skip_on_retry(
- exception=original_exception,
- already_skipped=kwargs.get("_retry_skipped_deployment_ids"),
- )
- if first_skipped_ids:
- kwargs["_retry_skipped_deployment_ids"] = first_skipped_ids # rebind-ok: the next attempt reads it
- if isinstance(original_exception, litellm.UnsupportedEndpointError) and _every_deployment_refused(
- _DEPLOYMENTS_ADAPTER.validate_python(_healthy_deployments), first_skipped_ids
- ):
- raise
- else:
- raise
-
- verbose_router_logger.debug("Retrying request with num_retries: %s", num_retries)
- # decides how long to sleep before retry
- retry_after: Final = self._time_to_sleep_before_retry(
- e=original_exception,
- remaining_retries=num_retries,
- num_retries=num_retries,
- healthy_deployments=_healthy_deployments,
- all_deployments=_all_deployments,
- )
-
- await asyncio.sleep(retry_after)
-
- for current_attempt in range(num_retries):
- try:
- # Update retry tracking metadata before each retry attempt
- _metadata["attempted_retries"] = current_attempt + 1
- _metadata["max_retries"] = num_retries
- # if the function call is successful, no exception will be raised and we'll break out of the loop
- response = await self.make_call(original_function, *args, **kwargs)
- if coroutine_checker.is_async_callable(response): # async errors are often returned as coroutines
- response = await response
-
- response = add_retry_headers_to_response(
- response=response,
- attempted_retries=current_attempt + 1,
- max_retries=num_retries,
- )
- return response
-
- except Exception as e:
- # Always track the latest error so we raise the most
- # recent exception instead of the first one.
- original_exception = e
-
- ## LOGGING
- kwargs = self._core_log_retry(kwargs=kwargs, e=e)
- remaining_retries = num_retries - current_attempt - 1
- _model: str | None = kwargs.get("model")
- if _model is not None:
- (
- _healthy_deployments,
- _,
- ) = await self._async_get_healthy_deployments(
- model=_model,
- parent_otel_span=parent_otel_span,
- request_kwargs=share_holder_request_kwargs(
- cast(Mapping[str, object], kwargs), # cast-ok: kwargs keys are param names
- self._ptu_share_teams(),
- ),
- )
- else:
- _healthy_deployments = []
-
- # Check if this error is non-retryable (e.g., 400 context
- # window exceeded). If so, raise immediately instead of
- # continuing the retry loop. Respect retry policy
- # precedence - only check when no retry policy applies.
- if not _retry_policy_applies:
- try:
- self.should_retry_this_error(
- error=e,
- healthy_deployments=_healthy_deployments,
- all_deployments=_all_deployments,
- context_window_fallbacks=context_window_fallbacks,
- regular_fallbacks=fallbacks,
- content_policy_fallbacks=content_policy_fallbacks,
- )
- except Exception:
- raise e
-
- skipped_ids = self._deployment_ids_to_skip_on_retry(
- exception=e,
- already_skipped=kwargs.get("_retry_skipped_deployment_ids"),
- )
- if skipped_ids:
- kwargs["_retry_skipped_deployment_ids"] = skipped_ids # rebind-ok: the next attempt reads it
- if isinstance(e, litellm.UnsupportedEndpointError) and _every_deployment_refused(
- _DEPLOYMENTS_ADAPTER.validate_python(_healthy_deployments), skipped_ids
- ):
- raise
- _timeout = self._time_to_sleep_before_retry(
- e=e,
- remaining_retries=remaining_retries,
- num_retries=num_retries,
- healthy_deployments=_healthy_deployments,
- all_deployments=_all_deployments,
- )
- await asyncio.sleep(_timeout)
-
- if type(original_exception) in litellm.LITELLM_EXCEPTION_TYPES:
- setattr(original_exception, "max_retries", num_retries)
- # current_attempt is 0-indexed (0 to num_retries-1), so after loop completion
- # it represents the last attempt index. The actual number of retries attempted
- # is current_attempt + 1, which equals num_retries when all retries are exhausted.
- # We've already verified num_retries > 0 before entering the loop, so current_attempt
- # will always be set (never None) when we reach this point.
- actual_retries_attempted: Final = current_attempt + 1 if current_attempt is not None else num_retries
- setattr(original_exception, "num_retries", actual_retries_attempted)
-
- raise original_exception
-
- async def make_call(self, original_function: Any, *args: object, **kwargs: object):
- """
- Handler for making a call to the .completion()/.embeddings()/etc. functions.
- """
- model_group: Final = kwargs.get("model")
- generic_function: Final = _OPAQUE_VALUE_ADAPTER.validate_python(kwargs.get("original_generic_function"))
- endpoint_function: Final = (
- generic_function if callable(generic_function) else _OPAQUE_VALUE_ADAPTER.validate_python(original_function)
- )
- try:
- with endpoint_selection(
- getattr(endpoint_function, "__name__", None),
- getattr(endpoint_function, "__module__", None),
- model=_OPAQUE_VALUE_ADAPTER.validate_python(model_group),
- ):
- response = original_function(*args, **kwargs)
- if coroutine_checker.is_async_callable(response) or inspect.isawaitable(response):
- response = await response
- except Exception:
- with contextlib.suppress(Exception):
- await async_io_token_refund_failure(
- self.cache, kwargs, parent_otel_span=get_parent_otel_span_from_kwargs(kwargs)
- )
- raise
- await self.increment_deployment_usage_for_response(response=response, request_kwargs=kwargs)
- ## PROCESS RESPONSE HEADERS
- response = await self.set_response_headers(
- response=response, model_group=model_group if isinstance(model_group, str) else None, request_kwargs=kwargs
- )
-
- return response
-
- def _handle_mock_testing_rate_limit_error(self, kwargs: dict, model_group: str | None = None):
- """
- Helper function to raise a mock litellm.RateLimitError error for testing purposes.
-
- Raises:
- litellm.RateLimitError error when `mock_testing_rate_limit_error=True` passed in request params
- """
- mock_testing_rate_limit_error: Final[bool | None] = kwargs.pop("mock_testing_rate_limit_error", None)
-
- available_models: Final = self._core_get_model_list(model_name=model_group)
- num_retries: int | None = None
-
- if available_models is not None and len(available_models) == 1:
- num_retries = available_models[0]["litellm_params"].get("num_retries")
-
- if mock_testing_rate_limit_error is not None and mock_testing_rate_limit_error is True:
- verbose_router_logger.info(
- "litellm.router.py::_mock_rate_limit_error() - Raising mock RateLimitError for model=%s", model_group
- )
- raise litellm.RateLimitError(
- model=model_group,
- llm_provider="",
- message=f"This is a mock exception for model={model_group}, to trigger a rate limit error.",
- num_retries=num_retries,
- )
-
- def should_retry_this_error(
- self,
- error: Exception,
- healthy_deployments: list | None = None,
- all_deployments: list | None = None,
- context_window_fallbacks: list | None = None,
- content_policy_fallbacks: list | None = None,
- regular_fallbacks: list | None = None,
- ):
- """
- 1. raise an exception for ContextWindowExceededError if context_window_fallbacks is not None
- 2. raise an exception for ContentPolicyViolationError if content_policy_fallbacks is not None
-
- 2. raise an exception for RateLimitError if
- - there are no fallbacks
- - there are no healthy deployments in the same model group
- """
- _num_healthy_deployments = 0
- if healthy_deployments is not None and isinstance(healthy_deployments, list):
- _num_healthy_deployments = len(healthy_deployments)
-
- _num_all_deployments = 0
- if all_deployments is not None and isinstance(all_deployments, list):
- _num_all_deployments = len(all_deployments)
-
- ### CHECK IF RATE LIMIT / CONTEXT WINDOW ERROR / CONTENT POLICY VIOLATION ERROR w/ fallbacks available / Bad Request Error
- if isinstance(error, litellm.ContextWindowExceededError) and context_window_fallbacks is not None:
- raise error
-
- if isinstance(error, litellm.ContentPolicyViolationError) and content_policy_fallbacks is not None:
- raise error
-
- status_code: Final = getattr(error, "status_code", None)
- if status_code is not None and not litellm.should_retry(status_code):
- # 401/403 are special cases - allow retry if multiple deployments exist (handled below)
- if status_code not in (401, 403) and not isinstance(error, litellm.UnsupportedEndpointError):
- raise error
-
- if isinstance(error, litellm.NotFoundError):
- raise error
- # Error we should only retry if there are other deployments
- if isinstance(error, openai.RateLimitError):
- if (
- _num_healthy_deployments <= 0 # if no healthy deployments
- and regular_fallbacks is not None # and fallbacks available
- and len(regular_fallbacks) > 0
- ):
- raise error # then raise the error
-
- if isinstance(error, (openai.AuthenticationError, openai.PermissionDeniedError)):
- """
- - if other deployments available -> retry
- - else -> raise error
- """
- if _num_all_deployments <= 1: # if there is only 1 deployment for this model group then don't retry
- raise error # then raise error
-
- # Do not retry if there are no healthy deployments
- # just raise the error
- if _num_healthy_deployments <= 0: # if no healthy deployments
- raise error
-
- return True
-
- def function_with_fallbacks(self, *args, **kwargs):
- """
- Sync wrapper for async_function_with_fallbacks
-
- Wrapped to reduce code duplication and prevent bugs.
- """
- return run_async_function(self.async_function_with_fallbacks, *args, **kwargs)
-
- def _get_fallback_model_group_for_lookup_groups(
- self,
- fallbacks: list[dict[str, list[str]]], # mutable-ok: mirrors the shared resolver's contract
- lookup_groups: tuple[str, ...],
- ) -> list[str] | None: # mutable-ok: mirrors the shared resolver's contract
- fallback_model_group, _ = get_fallback_model_group_for_lookup_groups(
- fallbacks=fallbacks, lookup_groups=lookup_groups
- )
- return fallback_model_group
-
- def _get_first_default_fallback(self) -> str | None:
- """
- Returns the first model from the default_fallbacks list, if it exists.
- """
- if self._core_fallbacks is None:
- return None
- for fallback in self._core_fallbacks:
- if isinstance(fallback, dict) and "*" in fallback:
- default_list = fallback["*"]
- if isinstance(default_list, list) and len(default_list) > 0:
- return default_list[0]
- return None
-
- def _time_to_sleep_before_retry(
- self,
- e: Exception,
- remaining_retries: int,
- num_retries: int,
- healthy_deployments: list | None = None,
- all_deployments: list | None = None,
- ) -> int | float:
- """
- Calculate back-off, then retry
-
- It should instantly retry only when:
- 1. there are healthy deployments in the same model group
- 2. there are fallbacks for the completion call
- """
-
- ## base case - single deployment
- if all_deployments is not None and len(all_deployments) == 1:
- pass
- elif healthy_deployments is not None and isinstance(healthy_deployments, list) and len(healthy_deployments) > 0:
- return 0
-
- response_headers: httpx.Headers | None = None
- if hasattr(e, "response") and hasattr(e.response, "headers"):
- response_headers = e.response.headers
- if hasattr(e, "litellm_response_headers"):
- response_headers = e.litellm_response_headers
-
- if response_headers is not None:
- timeout = litellm.calculate_retry_after(
- remaining_retries=remaining_retries,
- max_retries=num_retries,
- response_headers=response_headers,
- min_timeout=self.retry_after,
- )
-
- else:
- timeout = litellm.calculate_retry_after(
- remaining_retries=remaining_retries,
- max_retries=num_retries,
- min_timeout=self.retry_after,
- )
-
- return timeout
-
- ### HELPER FUNCTIONS
-
- async def deployment_callback_on_success(
- self,
- kwargs, # kwargs to completion
- completion_response, # response from completion
- start_time,
- end_time, # start/end time
- ):
- """
- Track remaining tpm/rpm quota for model in model_list
- """
- try:
- if kwargs.get("call_type") == "_arealtime":
+ if "_core" not in self.__dict__:
+ self.__dict__.setdefault("_pending", {})[name] = value
return
- if is_batch_retrieve_call_type(kwargs.get("call_type")):
+ core: Final = self._core
+ mirrored: Final = name in self.__dict__ or _patchable_method(core, name)
+ setattr(core, name, value)
+ if mirrored:
+ self.__dict__[name] = value
+
+ def __delattr__(self, name: str) -> None:
+ if _is_core(self):
+ super().__delattr__(name)
return
- standard_logging_object: Final[StandardLoggingPayload | None] = kwargs.get("standard_logging_object", None)
- if standard_logging_object is None:
- raise ValueError("standard_logging_object is None")
- litellm_params: Final = kwargs["litellm_params"]
- metadata: Final = litellm_params.get("metadata")
- if metadata is None:
- return
- model_group: Final = metadata.get("model_group", None)
- model_info: Final = litellm_params.get("model_info", {}) or {}
- deployment_id: Final = model_info.get("id", None)
- if (
- model_group is None
- or deployment_id is None
- or self._core_get_deployment(model_id=str(deployment_id)) is None
- ):
+ if _class_descriptor(type(self), name):
+ object.__delattr__(self, name)
return
+ self.__dict__.pop(name, None)
+ delattr(self._core, name)
- if self.session_affinity_repin_on_failover:
- await self._repin_session_after_success(
- _LITELLM_PARAMS_ADAPTER.validate_python(litellm_params),
- _OPAQUE_VALUE_ADAPTER.validate_python(completion_response),
- )
+ def __dir__(self) -> list[str]: # mutable-ok: dir() returns a list
+ if _is_core(self):
+ return super().__dir__()
+ return sorted({*super().__dir__(), *dir(self.__dict__.get("_core", type(self)._core_class))})
- # Always track deployment successes for cooldown logic, regardless of TPM/RPM limits
- increment_deployment_successes_for_current_minute(
- ctx=self._core_ctx,
- deployment_id=str(deployment_id),
- )
-
- total_tokens: Final[float] = standard_logging_object.get("total_tokens", 0)
- counted_tokens: Final = get_counted_usage_tokens(litellm_params)
- deployment_name: Final = metadata.get("deployment", None)
- return await self._increment_deployment_usage(
- deployment_id=str(deployment_id),
- deployment_name=deployment_name if isinstance(deployment_name, str) else None,
- model_group=model_group,
- total_tokens=total_tokens if counted_tokens is None else max(0, total_tokens - counted_tokens),
- rpm_increment=1 if counted_tokens is None else 0,
- parent_otel_span=get_parent_otel_span_from_kwargs(kwargs),
- )
-
- except Exception as e:
- verbose_router_logger.debug(
- "litellm.router.Router::deployment_callback_on_success(): Exception occured - %s", e
- )
-
- async def _repin_session_after_success(self, litellm_params: Mapping[str, object], response: object) -> None:
- if not self.session_affinity_repin_on_failover:
- return
- try:
- from litellm.router_utils.pre_call_checks.deployment_affinity_check import completed_affinity_response
-
- if not completed_affinity_response(response):
- return
- callback: Final = next(
- (logger for logger in self._optional_loggers() if isinstance(logger, DeploymentAffinityCheck)),
- None,
- )
- if callback is None:
- return
- model_group: Final = callback.get_repin_model_group(litellm_params)
- if model_group is None or self._is_priority_routing_group(model_group):
- return
- registered_ids: Final = self._core_get_candidate_model_ids_for_route(
- model=model_group, team_id=get_request_team_id(litellm_params)
- )
- cooldown_ids: Final = frozenset(
- model_id
- for model_id, _ in await self._core_cooldown_cache.async_get_active_cooldowns(
- model_ids=list(registered_ids), parent_otel_span=None
- )
- )
- await callback.async_repin_session_after_success(litellm_params, model_group, registered_ids - cooldown_ids)
- except Exception as error: # noqa: BLE001 # Optional pin updates must not interrupt success accounting
- verbose_router_logger.debug("Session re-pinning failed: %s", error)
-
- async def increment_deployment_usage_for_response(
- self,
- response: object,
- request_kwargs: dict[str, object],
- ) -> None:
- if response is None:
- return
- try:
- deployment_metadata: Final = find_deployment_metadata(request_kwargs)
- model_group: Final = request_kwargs.get("model")
- if deployment_metadata is None or not isinstance(model_group, str):
- return
- model_info: Final = deployment_metadata["model_info"]
- deployment_id: Final = model_info.get("id") if isinstance(model_info, dict) else None
- if deployment_id is None:
- return
- total_tokens: Final = response_total_token_count(response)
- deployment_name: Final = deployment_metadata.get("deployment")
- deployment_metadata[ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY] = total_tokens
- try:
- await self._increment_deployment_usage(
- deployment_id=str(deployment_id),
- deployment_name=deployment_name if isinstance(deployment_name, str) else None,
- model_group=model_group,
- total_tokens=total_tokens,
- rpm_increment=1,
- parent_otel_span=get_parent_otel_span_from_kwargs(request_kwargs),
- )
- except Exception:
- deployment_metadata.pop(ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY, None)
- raise
- except Exception as e:
- verbose_router_logger.debug(
- "litellm.router.Router::increment_deployment_usage_for_response(): Exception occured - %s", e
- )
-
- async def _increment_deployment_usage(
- self,
- *,
- deployment_id: str,
- deployment_name: str | None,
- model_group: str,
- total_tokens: float,
- rpm_increment: int,
- parent_otel_span: Span | None,
- ) -> str | None:
- from litellm.types.caching import RedisPipelineIncrementOperation
-
- if total_tokens <= 0 and rpm_increment <= 0:
- return None
- if not self._cached_deployment_tracks_usage(deployment_id, model_group):
- return None
-
- current_minute: Final = get_utc_datetime().strftime("%H-%M") # use the same timezone regardless of system clock
- tpm_key: Final = RouterCacheEnum.TPM.value.format(
- id=deployment_id, current_minute=current_minute, model=deployment_name
- )
- rpm_key: Final = RouterCacheEnum.RPM.value.format(
- id=deployment_id, current_minute=current_minute, model=deployment_name
- )
- pipeline_operations: Final[list[RedisPipelineIncrementOperation]] = [
- RedisPipelineIncrementOperation(key=key, increment_value=increment_value, ttl=RoutingArgs.ttl.value)
- for key, increment_value in ((tpm_key, total_tokens), (rpm_key, rpm_increment))
- ]
- post_increment_values: Final = await self._core_cache.async_increment_cache_pipeline(
- increment_list=pipeline_operations,
- parent_otel_span=parent_otel_span,
- )
- if post_increment_values is not None and self._core_cache.redis_cache is not None:
- for operation, value in zip(pipeline_operations, post_increment_values):
- await self._core_cache.async_set_cache(
- operation["key"], int(value), local_only=True, ttl=RoutingArgs.ttl.value
- )
- return tpm_key
-
- def _deployment_tracks_usage(self, deployment_id: str, model_group: str) -> bool:
- deployment_info: Final = self._core_get_deployment(model_id=deployment_id)
- if deployment_info is None:
- return False
- deployment_model_info: Final = self._core_get_router_model_info(
- deployment=deployment_info,
- received_model_name=model_group,
- )
- configured_limits: Final = (
- deployment_info.get("tpm", None),
- deployment_info.get("rpm", None),
- deployment_info.litellm_params.tpm,
- deployment_info.litellm_params.rpm,
- deployment_model_info.get("tpm", None),
- deployment_model_info.get("rpm", None),
- )
- ## Nothing to track only when neither tpm/rpm nor itpm/otpm limits are
- ## set. IO deployments still record TPM/RPM usage here so TPM-aware
- ## routing strategies see their real load in mixed model groups; their
- ## itpm/otpm enforcement runs separately in ModelRateLimitingCheck.
- return any(limit is not None for limit in configured_limits) or deployment_has_io_token_limits(
- deployment_info.model_dump()
- )
-
- def sync_deployment_callback_on_success(
- self,
- kwargs, # kwargs to completion
- completion_response, # response from completion
- start_time,
- end_time, # start/end time
- ) -> str | None:
- """
- Tracks the number of successes for a deployment in the current minute (using in-memory cache)
-
- Returns:
- - key: str - The key used to increment the cache
- - None: if no key is found
- """
- if is_batch_retrieve_call_type(kwargs.get("call_type")):
- return None
- id = None
- if kwargs["litellm_params"].get("metadata") is None:
- pass
- else:
- model_group: Final = kwargs["litellm_params"]["metadata"].get("model_group", None)
- model_info: Final = kwargs["litellm_params"].get("model_info", {}) or {}
- id = model_info.get("id", None)
- if model_group is None or id is None:
- return None
- elif isinstance(id, int):
- id = str(id)
-
- if id is not None:
- key: Final = increment_deployment_successes_for_current_minute(
- ctx=self._core_ctx,
- deployment_id=id,
- )
- return key
-
- return None
-
- def deployment_callback_on_failure(
- self,
- kwargs, # kwargs to completion
- completion_response, # response from completion
- start_time,
- end_time, # start/end time
- ) -> bool:
- """
- 2 jobs:
- - Tracks the number of failures for a deployment in the current minute (using in-memory cache)
- - Puts the deployment in cooldown if it exceeds the allowed fails / minute
-
- Returns:
- - True if the deployment should be put in cooldown
- - False if the deployment should not be put in cooldown
- """
- verbose_router_logger.debug("Router: Entering 'deployment_callback_on_failure'")
- try:
- exception: Final = kwargs.get("exception", None)
-
- if is_failure_outside_selected_deployment(exception):
- verbose_router_logger.debug(
- "Router: Exiting 'deployment_callback_on_failure' without cooldown. "
- "Failure came from advisor orchestration or deployment selection, not the selected deployment."
- )
- return False
-
- # Cache litellm_params to avoid repeated dict lookups
- litellm_params: Final = kwargs.get("litellm_params", {})
- _model_info: Final = litellm_params.get("model_info", {})
-
- if is_background_response_poll_not_found(exception, litellm_params):
- verbose_router_logger.debug(
- "Router: Exiting 'deployment_callback_on_failure' without cooldown. "
- "Provider 404 came from the background response poll, not the deployment's health."
- )
- return False
-
- if is_video_lookup_not_found(exception, kwargs):
- verbose_router_logger.debug(
- "Router: Exiting 'deployment_callback_on_failure' without cooldown. "
- "Provider 404 says the video is not on this deployment's account, not that the deployment is down."
- )
- return False
-
- exception_status: Final = getattr(exception, "status_code", "")
-
- if is_caller_timeout_408(kwargs, exception_status):
- verbose_router_logger.debug(
- "Router: Exiting 'deployment_callback_on_failure' without cooldown. "
- "A timeout the caller set caused this 408, not the deployment's health."
- )
- return False
-
- caller_failure_model_info: Final = (
- _MODEL_INFO_ADAPTER.validate_python(_model_info) if isinstance(_model_info, dict) else None
- )
- caller_failure_model_id: Final = (
- caller_failure_model_info.get("id") if caller_failure_model_info is not None else None
- )
- caller_failure_deployment: Final = (
- self._core_get_deployment(model_id=caller_failure_model_id)
- if isinstance(caller_failure_model_id, str)
- else None
- )
- if is_caller_scoped_auth_failure(caller_failure_deployment, exception_status):
- verbose_router_logger.debug(
- "Router: Exiting 'deployment_callback_on_failure' without cooldown. "
- "Caller-scoped OAuth authentication failed, not the deployment."
- )
- return False
-
- exception_headers: Final = litellm.litellm_core_utils.exception_mapping_utils._get_response_headers(
- original_exception=exception
- )
-
- # Determine cooldown time with priority: deployment config > response header > router default
- deployment_cooldown: Final = _first_present(
- _model_info if isinstance(_model_info, dict) else None, litellm_params, key="cooldown_time"
- )
-
- header_cooldown = None
- if exception_headers is not None:
- header_cooldown = litellm.utils._get_retry_after_from_exception_header(
- response_headers=exception_headers
- )
- ##############################################
- # Logic to determine cooldown time
- # 1. Check if a cooldown time is set in the deployment config
- # 2. Check if a cooldown time is set in the response header
- # 3. If no cooldown time is set, use the router default cooldown time
- ##############################################
- if deployment_cooldown is not None and deployment_cooldown >= 0:
- _time_to_cooldown = deployment_cooldown
- elif header_cooldown is not None and header_cooldown >= 0:
- _time_to_cooldown = header_cooldown
- else:
- _time_to_cooldown = self._core_cooldown_time
-
- if isinstance(_model_info, dict):
- deployment_id: Final[str | None] = _model_info.get("id")
- if deployment_id is None:
- return False
- increment_deployment_failures_for_current_minute(
- ctx=self._core_ctx,
- deployment_id=deployment_id,
- )
- result: Final = set_cooldown_deployments(
- ctx=self._core_ctx,
- exception_status=exception_status,
- original_exception=exception,
- deployment=deployment_id,
- time_to_cooldown=_time_to_cooldown,
- requested_model_group=(
- get_litellm_metadata_from_kwargs(
- cast("dict[str, object]", kwargs) # cast-ok: untyped kwargs dict
- )
- or {}
- ).get("model_group"),
- request_kwargs=cast("dict[str, object]", kwargs), # cast-ok: kwargs is the untyped request dict
- ) # setting deployment_id in cooldown deployments
-
- return result
- else:
- verbose_router_logger.debug(
- "Router: Exiting 'deployment_callback_on_failure' without cooldown. No model_info found."
- )
- return False
-
- except Exception as e:
- raise e
-
- async def async_deployment_callback_on_failure(
- self, kwargs, completion_response: object | None, start_time, end_time
- ):
- """
- Update RPM usage for a deployment
- """
- if is_batch_retrieve_call_type(kwargs.get("call_type")):
- return
- deployment_name: Final = kwargs["litellm_params"]["metadata"].get(
- "deployment", None
- ) # handles wildcard routes - by giving the original name sent to `litellm.completion`
- model_group: Final = kwargs["litellm_params"]["metadata"].get("model_group", None)
- model_info: Final = kwargs["litellm_params"].get("model_info", {}) or {}
- id = model_info.get("id", None)
- if model_group is None or id is None:
- return
- elif isinstance(id, int):
- id = str(id)
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
-
- dt: Final = get_utc_datetime()
- current_minute: Final = dt.strftime("%H-%M") # use the same timezone regardless of system clock
-
- ## RPM
- rpm_key: Final = RouterCacheEnum.RPM.value.format(id=id, current_minute=current_minute, model=deployment_name)
- with service_target(ROUTER_USAGE_TARGET):
- await self._core_cache.async_increment_cache(
- key=rpm_key,
- value=1,
- parent_otel_span=parent_otel_span,
- ttl=RoutingArgs.ttl.value,
- )
-
- def _get_metadata_variable_name_from_kwargs(self, kwargs: dict) -> Literal["metadata", "litellm_metadata"]:
- """
- Helper to return what the "metadata" field should be called in the request data
-
- - New endpoints return `litellm_metadata`
- - Old endpoints return `metadata`
-
- Context:
- - LiteLLM used `metadata` as an internal field for storing metadata
- - OpenAI then started using this field for their metadata
- - LiteLLM is now moving to using `litellm_metadata` for our metadata
- """
- return get_metadata_variable_name_from_kwargs(kwargs)
-
- def _core_log_retry(self, kwargs: dict, e: Exception) -> dict:
- """
- When a retry or fallback happens, record which model group, deployment and attempt just failed and why,
- and count it toward the request-wide num_retries_per_request cap
- """
- from litellm.litellm_core_utils.get_litellm_params import get_base_model_from_litellm_call_metadata
- from litellm.types.router import RetryAttemptRecord
-
- _metadata_var: Final = "litellm_metadata" if "litellm_metadata" in kwargs else "metadata"
- request_metadata: Final = cast("Mapping[str, object]", kwargs[_metadata_var]) # cast-ok: untyped kwargs
- model_group: Final = kwargs.get("model")
- model_info: Final = request_metadata.get("model_info")
- deployment_id: Final = model_info.get("id") if isinstance(model_info, Mapping) else None
- base_model: Final = get_base_model_from_litellm_call_metadata(
- metadata=request_metadata
- ) or self._deployment_params_base_model(deployment_id if isinstance(deployment_id, str) else None)
- deployment_model: Final = request_metadata.get("deployment")
- attempted_retries: Final = request_metadata.get("attempted_retries")
- attempt_record: Final[RetryAttemptRecord] = {
- "model_group": model_group if isinstance(model_group, str) else None,
- "deployment_id": deployment_id if isinstance(deployment_id, str) else None,
- "model": deployment_model if isinstance(deployment_model, str) else None,
- "base_model": base_model if isinstance(base_model, str) else None,
- "exception_type": type(e).__name__,
- "exception_string": str(e),
- "attempted_retries": attempted_retries if type(attempted_retries) is int else None,
- }
- earlier_breadcrumbs: Final = request_metadata.get("previous_models")
- kept_breadcrumbs: Final[tuple[object, ...]] = (
- tuple(earlier_breadcrumbs)[-(RETRY_BREADCRUMB_LIMIT - 1) :]
- if isinstance(earlier_breadcrumbs, (list, tuple))
- else ()
- )
- breadcrumbs: Final = (*kept_breadcrumbs, attempt_record)
- earlier: Final = request_metadata.get("request_retry_count")
- request_retry_count: Final = (earlier if type(earlier) is int and 0 <= earlier else 0) + 1
- decision: Final = request_metadata.get("routing_decision")
- router_name: Final = decision.get("router_model_name") if isinstance(decision, Mapping) else None
- if isinstance(router_name, str):
- phase_event(
- "litellm.routing.retry",
- {
- "litellm.routing.router_model_name": router_name,
- "litellm.retry.count": request_retry_count,
- "error.type": type(e).__name__,
- **({"litellm.deployment.model_group": model_group} if isinstance(model_group, str) else {}),
- **({"litellm.deployment.id": deployment_id} if isinstance(deployment_id, str) else {}),
- },
- )
- kwargs[_metadata_var]["previous_models"] = breadcrumbs # rebind-ok: the logging object already holds this dict
- kwargs[_metadata_var]["request_retry_count"] = request_retry_count # rebind-ok: same dict, read by the cap
- return kwargs
-
- def _deployment_params_base_model(self, deployment_id: str | None) -> str | None:
- """The ``base_model`` a deployment declares under ``litellm_params`` (the Azure named-deployment
- shape), the same key the success path's ``get_base_model_from_metadata`` reads."""
- deployment: Final = self._core_get_deployment(model_id=deployment_id) if deployment_id is not None else None
- if deployment is None:
- return None
- extra_params: Final = cast( # cast-ok: pydantic extras are untyped, base_model is isinstance-checked below
- "Mapping[str, object]", deployment.litellm_params.model_extra or {}
- )
- base_model: Final = extra_params.get("base_model")
- return base_model if isinstance(base_model, str) else None
-
- def _update_usage(self, deployment_id: str, parent_otel_span: Span | None) -> int:
- """
- Update deployment rpm for that minute
-
- Returns:
- - int: request count
- """
- rpm_key: Final = deployment_id
-
- request_count = self._core_cache.get_cache(key=rpm_key, parent_otel_span=parent_otel_span, local_only=True)
- if request_count is None:
- request_count = 1
- self._core_cache.set_cache(key=rpm_key, value=request_count, local_only=True, ttl=60) # only store for 60s
- else:
- request_count += 1
- self._core_cache.set_cache(key=rpm_key, value=request_count, local_only=True) # don't change existing ttl
-
- return request_count
-
- def _has_default_fallbacks(self) -> bool:
- if self._core_fallbacks is None:
- return False
- for fallback in self._core_fallbacks:
- if isinstance(fallback, dict):
- if "*" in fallback:
- return True
- return False
-
- def _has_content_policy_fallback(self, model_group: str, kwargs: Mapping[str, Any]) -> bool:
- """
- Whether a content-policy fallback would resolve for this request, keyed the same way
- async_function_with_fallbacks_common_utils resolves it: the tier a pre-routing hook
- selected wins over the requested group. Raising without this returning True would turn
- a deliverable response into an error the fallback chain cannot recover from.
- """
- content_policy_fallbacks: Final = kwargs.get("content_policy_fallbacks", self._core_content_policy_fallbacks)
- if content_policy_fallbacks is not None:
- return has_unattempted_fallback_target(
- self._get_fallback_model_group_for_lookup_groups(
- fallbacks=content_policy_fallbacks,
- lookup_groups=fallback_lookup_groups(kwargs, model_group),
- ),
- kwargs,
- )
- if self._has_default_fallbacks():
- return True
- verbose_router_logger.debug(
- "No content-policy fallback available. Returning original response. model=%s, content_policy_fallbacks=%s",
- model_group,
- content_policy_fallbacks,
- )
- return False
-
- def _refusal_fallback_available(self, model_group: str, kwargs: Mapping[str, Any]) -> bool:
- """
- Whether a safeguard refusal can actually be recovered by the dispatcher. A configured
- content-policy list is authoritative; with none configured at all, the dispatcher falls
- through to the generic fallbacks lookup, so the gate mirrors that reachability and arms
- on a resolving generic chain (tier first, then the requested group, then "*").
- """
- if fallbacks_disabled_for_request(kwargs):
- return False
- content_policy_fallbacks: Final = kwargs.get("content_policy_fallbacks", self._core_content_policy_fallbacks)
- if content_policy_fallbacks is not None:
- return self._has_content_policy_fallback(model_group, kwargs)
- if self._has_default_fallbacks():
- return True
- fallbacks: Final = kwargs.get("fallbacks", self._core_fallbacks)
- if fallbacks is None:
- return False
- resolved, _ = get_fallback_model_group_for_lookup_groups(
- fallbacks=fallbacks,
- lookup_groups=fallback_lookup_groups(kwargs, model_group),
- )
- return has_unattempted_fallback_target(resolved, kwargs)
-
- def _stream_order_levels(self, model_group: str, kwargs: Mapping[str, Any]) -> tuple[int, ...]:
- """
- The distinct deployment order levels the fallback dispatcher would see for this request,
- computed the same way: the tier a pre-routing hook selected wins over the requested group.
- """
- request_team_id: Final[str | None] = (kwargs.get("metadata", {}) or {}).get("user_api_key_team_id")
- order_model_group: Final = get_pre_routing_selection(kwargs) or model_group
- all_deployments: Final = self._core_get_model_list(model_name=order_model_group, team_id=request_team_id) or ()
- return tuple(
- sorted(
- {
- litellm.utils.get_deployment_order(d)
- for d in all_deployments
- if litellm.utils.get_deployment_order(d) is not None
- }
- )
- )
-
- def _stream_can_fall_back(self, model_group: str, kwargs: Mapping[str, Any]) -> bool:
- """
- Whether async_function_with_fallbacks_common_utils could still route a
- MidStreamFallbackError somewhere for this request (order levels, weighted
- failover, content-policy or generic fallbacks), which is the only case where
- holding lifecycle frames back from the client buys a clean retry. Errs toward
- True whenever a dispatcher path might reach a fallback.
- """
- if fallbacks_disabled_for_request(kwargs):
- return False
- if self.enable_weighted_failover:
- return True
- order_levels: Final = self._stream_order_levels(model_group, kwargs)
- if len(order_levels) > 1:
- current_target: Final = kwargs.get("_target_order")
- skip_up_to: Final = current_target if current_target is not None else order_levels[0]
- if any(o > skip_up_to for o in order_levels):
- return True
- content_policy_fallbacks: Final = kwargs.get("content_policy_fallbacks", self._core_content_policy_fallbacks)
- if content_policy_fallbacks is not None and self._has_content_policy_fallback(model_group, kwargs):
- return True
- fallbacks: Final = kwargs.get("fallbacks", self._core_fallbacks)
- if not fallbacks:
- return False
- if check_non_standard_fallback_format(fallbacks=fallbacks):
- return True
- resolved, _ = get_fallback_model_group_for_lookup_groups(
- fallbacks=fallbacks,
- lookup_groups=fallback_lookup_groups(kwargs, model_group),
- )
- return has_unattempted_fallback_target(resolved, kwargs)
-
- def _should_raise_content_policy_error(self, model: str, response: ModelResponse, kwargs: dict) -> bool:
- """
- Determines if a content policy error should be raised.
-
- Only raised if a fallback is available.
-
- Else, original response is returned.
- """
- if response.choices and len(response.choices) > 0:
- if response.choices[0].finish_reason != "content_filter":
- return False
-
- return self._has_content_policy_fallback(model, kwargs)
-
- def _should_raise_anthropic_refusal_error(
- self, model: str, original_generic_function: Callable, response: object, kwargs: Mapping[str, object]
- ) -> bool:
- """
- The /v1/messages twin of _should_raise_content_policy_error: an Anthropic safeguard
- refusal (stop_reason "refusal" carrying stop_details) re-enters the fallback chain only
- when a content-policy fallback is configured; a plain refusal without stop_details, or
- any response with nothing configured, is returned to the client unchanged.
- """
- from litellm.llms.anthropic.messages.native.utils import (
- get_safeguard_refusal_stop_details,
- )
-
- if getattr(original_generic_function, "__name__", "") != "anthropic_messages":
- return False
- if get_safeguard_refusal_stop_details(response) is None:
- return False
- return self._refusal_fallback_available(model, kwargs)
-
- def _get_healthy_deployments(self, model: str, parent_otel_span: Span | None):
- _all_deployments: list = []
- try:
- _, _all_deployments = self._common_checks_available_deployment(
- model=model,
- )
- if isinstance(_all_deployments, dict):
- return []
- except Exception:
- pass
-
- unhealthy_deployments: Final = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
- unhealthy_set: Final = set(unhealthy_deployments)
- healthy_deployments: list = [d for d in _all_deployments if d["model_info"]["id"] not in unhealthy_set]
- healthy_deployments = self._filter_blocked_deployments(healthy_deployments)
-
- return healthy_deployments, _all_deployments
-
- async def _async_get_healthy_deployments(
- self, model: str, parent_otel_span: Span | None, request_kwargs: Mapping[str, object] | None = None
- ) -> tuple[list[dict], list[dict]]:
- """
- Returns Tuple of:
- - Tuple[List[Dict], List[Dict]]:
- 1. healthy_deployments: list of healthy deployments the caller of ``request_kwargs`` may use
- 2. all_deployments: list of all deployments that caller may use
- """
- _all_deployments: list = []
- try:
- _, _all_deployments = self._common_checks_available_deployment(
- model=model,
- request_kwargs=request_kwargs,
- )
- if isinstance(_all_deployments, dict):
- return [], _all_deployments
- except Exception:
- pass
-
- unhealthy_deployments: Final = await async_get_cooldown_deployments(
- ctx=self._core_ctx, parent_otel_span=parent_otel_span
- )
- # Convert to set for O(1) lookup instead of O(n)
- unhealthy_deployments_set: Final = set(unhealthy_deployments)
- healthy_deployments: list = [
- d for d in _all_deployments if d["model_info"]["id"] not in unhealthy_deployments_set
- ]
- healthy_deployments = self._filter_blocked_deployments(healthy_deployments)
- return healthy_deployments, _all_deployments
-
- def routing_strategy_pre_call_checks(self, deployment: dict):
- """
- Mimics 'async_routing_strategy_pre_call_checks'
-
- Ensures consistent update rpm implementation for 'usage-based-routing-v2'
-
- Returns:
- - None
-
- Raises:
- - Rate Limit Exception - If the deployment is over it's tpm/rpm limits
- """
- for _callback in self._routing_callbacks():
- if isinstance(_callback, CustomLogger):
- _callback.pre_call_check(deployment)
-
- async def async_routing_strategy_pre_call_checks(
- self,
- deployment: dict,
- parent_otel_span: Span | None,
- logging_obj: LiteLLMLogging | None = None,
- ):
- """
- For usage-based-routing-v2, enables running rpm checks before the call is made, inside the semaphore.
-
- -> makes the calls concurrency-safe, when rpm limits are set for a deployment
-
- Returns:
- - None
-
- Raises:
- - Rate Limit Exception - If the deployment is over it's tpm/rpm limits
- """
- for _callback in self._routing_callbacks():
- if isinstance(_callback, CustomLogger):
- try:
- await _callback.async_pre_call_check(deployment, parent_otel_span)
- except litellm.RateLimitError as e:
- ## LOG FAILURE EVENT
- if logging_obj is not None:
- asyncio.create_task(
- logging_obj.dispatch_failure_handlers(
- exception=e,
- traceback_exception=traceback.format_exc(),
- prefer_async_handlers=True,
- )
- )
- set_cooldown_deployments(
- ctx=self._core_ctx,
- exception_status=e.status_code,
- original_exception=e,
- deployment=deployment["model_info"]["id"],
- time_to_cooldown=self._core_cooldown_time,
- )
- raise e
- except Exception as e:
- ## LOG FAILURE EVENT
- if logging_obj is not None:
- asyncio.create_task(
- logging_obj.dispatch_failure_handlers(
- exception=e,
- traceback_exception=traceback.format_exc(),
- prefer_async_handlers=True,
- )
- )
- raise e
-
- def _deployment_as_configured_now(self, deployment: Mapping[str, object]) -> Mapping[str, object]:
- model_info: Final = deployment.get("model_info")
- model_id: Final = (
- _MODEL_INFO_ADAPTER.validate_python(model_info).get("id") if isinstance(model_info, dict) else None
- )
- current: Final = self._core_get_deployment(model_id) if isinstance(model_id, str) else None
- return deployment if current is None else current.model_dump(exclude_none=True)
-
- async def async_admit_deployment_turn(
- self,
- model_group: str,
- opened_on: Mapping[str, object],
- request_kwargs: Mapping[str, object],
- ) -> Mapping[str, object]:
- deployment: Final = self._deployment_as_configured_now(opened_on)
- rate_limit_check: Final = next(
- (callback for callback in self._routing_callbacks() if isinstance(callback, ModelRateLimitingCheck)), None
- )
- io_token_admission: Final[Mapping[str, object]] = (
- MappingProxyType({})
- if rate_limit_check is None
- else await async_admit_io_token_turn(rate_limit_check.dual_cache, deployment, get_utc_datetime())
- )
- budget_limiter: Final = self._core_get_router_deployment_budget_limiter()
- if budget_limiter is not None:
- try:
- await budget_limiter.async_filter_deployments( # pyright: ignore[reportUnknownMemberType] # legacy signature takes untyped deployment dicts
- model=model_group,
- healthy_deployments=[dict(deployment)],
- messages=None,
- request_kwargs=dict(request_kwargs),
- )
- except ValueError as e:
- raise litellm.RateLimitError(
- message=str(e),
- llm_provider="",
- model=model_group,
- category=litellm.RateLimitErrorCategory.LITELLM_RATE_LIMIT,
- ) from e
- await self._admit_turn_through_routing_strategy(model_group, deployment, request_kwargs)
- await self.async_routing_strategy_pre_call_checks( # pyright: ignore[reportUnknownMemberType] # legacy signature takes an untyped deployment dict
- deployment=dict(without_io_token_limits(deployment)), parent_otel_span=None
- )
- return io_token_admission
-
- async def _admit_turn_through_routing_strategy(
- self, model_group: str, deployment: Mapping[str, object], request_kwargs: Mapping[str, object]
- ) -> None:
- routing_kwargs: Final = {key: value for key, value in request_kwargs.items() if key != "litellm_logging_obj"}
- strategy, selector = self._get_routing_context( # pyright: ignore[reportUnknownMemberType] # legacy signature takes an untyped request dict
- model=model_group, request_kwargs=routing_kwargs
- )
- if strategy == "simple-shuffle" or selector is None:
- return
- selected: Final = await self._select_deployment_async( # pyright: ignore[reportUnknownMemberType] # legacy signature takes untyped deployment dicts
- strategy=strategy,
- selector=selector,
- model=model_group,
- healthy_deployments=[dict(deployment)],
- messages=None,
- input=None,
- request_kwargs=routing_kwargs,
- )
- if selected is None:
- no_deployment: Final = await async_raise_no_deployment_exception(
- ctx=self._core_ctx, model=model_group, parent_otel_span=None
- )
- raise litellm.RateLimitError(
- message=str(no_deployment),
- llm_provider="",
- model=model_group,
- category=litellm.RateLimitErrorCategory.LITELLM_RATE_LIMIT,
- ) from no_deployment
- await self._async_override_selector_pre_call_check( # pyright: ignore[reportUnknownMemberType] # legacy signature takes an untyped deployment dict
- strategy, selector, dict(deployment), None
- )
-
- @contextlib.asynccontextmanager
- async def _deployment_slot(
- self, deployment: dict, kwargs: Mapping[str, object], parent_otel_span: Span | None
- ) -> AsyncGenerator[None, None]:
- """Holds the deployment's max_parallel_requests slot, if it has one, around the provider call. Routing
- strategy pre-call checks run inside the slot so their rpm accounting stays concurrency-safe."""
- max_parallel_requests_limit: Final = self._get_client(
- deployment=deployment,
- kwargs=kwargs,
- client_type="max_parallel_requests",
- )
- async with contextlib.AsyncExitStack() as slot:
- if isinstance(max_parallel_requests_limit, MaxParallelRequestsLimit):
- slot.enter_context(max_parallel_requests_limit)
- await self.async_routing_strategy_pre_call_checks(deployment=deployment, parent_otel_span=parent_otel_span)
- yield
-
- async def async_callback_filter_deployments(
- self,
- model: str,
- healthy_deployments: list[dict],
- messages: list[AllMessageValues] | None,
- parent_otel_span: Span | None,
- request_kwargs: dict | None = None,
- logging_obj: LiteLLMLogging | None = None,
- ):
- """
- For usage-based-routing-v2, enables running rpm checks before the call is made, inside the semaphore.
-
- -> makes the calls concurrency-safe, when rpm limits are set for a deployment
-
- Returns:
- - None
-
- Raises:
- - Rate Limit Exception - If the deployment is over it's tpm/rpm limits
- """
- returned_healthy_deployments = healthy_deployments
- for _callback in self._routing_callbacks():
- if isinstance(_callback, CustomLogger):
- try:
- returned_healthy_deployments = await _callback.async_filter_deployments(
- model=model,
- healthy_deployments=returned_healthy_deployments,
- messages=messages,
- request_kwargs=request_kwargs,
- parent_otel_span=parent_otel_span,
- )
- except Exception as e:
- ## LOG FAILURE EVENT
- if logging_obj is not None:
- asyncio.create_task(
- logging_obj.dispatch_failure_handlers(
- exception=e,
- traceback_exception=traceback.format_exc(),
- prefer_async_handlers=True,
- )
- )
- raise e
- return returned_healthy_deployments
-
- @staticmethod
- def _json_default_stable_id(value: object) -> str:
- """json.dumps default= for generate_model_id: plain str() on an arbitrary
- object (e.g. a RoutingPlugin instance) falls back to object.__repr__'s
- ``, so the hash -- and deployment id -- would
- change every restart. Use the class name instead, stable across restarts."""
- return f"{type(value).__module__}.{type(value).__qualname__}"
-
- @staticmethod
- def _core_generate_model_id(model_group: str, litellm_params: dict) -> str: # mutable-ok: hashed read-only
- """The stable id of a deployment without ``model_info.id``; see ``litellm.router_api.deployment_id``."""
- return _deployment_id(model_group, litellm_params)
-
- @staticmethod
- def _inherit_builtin_cache_pricing(model_info: dict, backend_model: str, custom_llm_provider: str | None) -> None:
- """Fill missing cache pricing on a custom-priced deployment entry from
- the backend model's built-in cost map entry, so a deployment that
- only spells out ``input_cost_per_token``/``output_cost_per_token``
- does not silently bill cache_read/cache_creation at 0.
-
- User-specified cache fields always win; only ``None``/missing entries
- are inherited, and a user-set cache_read price also covers explicit
- cache hits. No-op when the backend model has no canonical entry.
- """
- cache_fields: Final = (
- "cache_creation_input_token_cost",
- "cache_creation_input_token_cost_above_1hr",
- "cache_creation_input_token_cost_above_200k_tokens",
- "cache_read_input_token_cost",
- "cache_read_input_token_cost_above_200k_tokens",
- )
- inherited_fields: Final = (
- cache_fields
- if model_info.get("cache_read_input_token_cost") is not None
- else (*cache_fields, "explicit_cache_read_input_token_cost")
- )
- missing_fields: Final = tuple(f for f in inherited_fields if model_info.get(f) is None)
- if not missing_fields:
- return
- try:
- backend_info: Final = litellm.get_model_info(model=backend_model, custom_llm_provider=custom_llm_provider)
- except Exception:
- return
- for field in missing_fields:
- backend_value = backend_info.get(field)
- if backend_value is not None:
- model_info[field] = backend_value
-
- @staticmethod
- def _cost_map_backend_model(deployment: Deployment | DeploymentTypedDict) -> str:
- model_info_base_model: Final = (
- deployment.model_info.base_model
- if isinstance(deployment, Deployment)
- else _configured_model_info(deployment).get("base_model")
- )
- if isinstance(model_info_base_model, str) and model_info_base_model:
- return model_info_base_model
- params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(
- deployment.litellm_params.model_dump(exclude_none=True)
- if isinstance(deployment, Deployment)
- else deployment["litellm_params"]
- )
- params_base_model: Final = params.get("base_model")
- if isinstance(params_base_model, str) and params_base_model:
- return params_base_model
- model: Final = (
- deployment.litellm_params.model
- if isinstance(deployment, Deployment)
- else deployment["litellm_params"].get("model", "")
- )
- invoked_model: Final = params.get("model_id")
- if (
- isinstance(invoked_model, str)
- and invoked_model
- and (params.get("custom_llm_provider") == "bedrock" or model.startswith("bedrock/"))
- ):
- profile_model: Final = invoked_model if invoked_model.startswith("bedrock/") else f"bedrock/{invoked_model}"
- profile_info: Final = lookup_model_info_for_registration(model=profile_model, custom_llm_provider="bedrock")
- if not isinstance(profile_info, ModelInfoLookupMiss):
- return profile_model
- return model
-
- @staticmethod
- def _inherit_builtin_service_tier_pricing(
- model_info: dict, # mutable-ok: deployment cost-map entry filled in place
- backend_model: str,
- custom_llm_provider: str | None,
- ) -> None:
- """Inherit missing tier rates so a standalone entry does not fall back to custom standard rates."""
- if ptu_terms(model_info) is not None and is_ptu_cost_attribution_enabled():
- return
- if all(model_info.get(field) is None for field in ("input_cost_per_token", "output_cost_per_token")):
- return
- try:
- backend_info: Final = litellm.get_model_info(model=backend_model, custom_llm_provider=custom_llm_provider)
- except Exception: # noqa: BLE001 # get_model_info raises plain Exception for an unmapped backend model
- return
- backend_entry: Final = litellm.model_cost.get(backend_info.get("key") or "")
- if not isinstance(backend_entry, dict):
- return
- for field, backend_value in backend_entry.items():
- if not field.endswith(SERVICE_TIER_COST_KEY_SUFFIXES):
- continue
- if model_info.get(field) is not None or backend_value is None:
- continue
- model_info[field] = copy.deepcopy(backend_value)
-
- @staticmethod
- def _inherit_builtin_base_rates_for_off_peak(
- model_info: dict, # mutable-ok: cost-map entry filled in place
- backend_model: str,
- custom_llm_provider: str | None,
- ) -> None:
- """Fill missing pricing fields on a deployment entry that only sets
- ``off_peak_pricing``, from the backend model's built-in cost map entry.
-
- Cost lookup selects the deployment-scoped entry over the shared backend
- entry only when the deployment entry carries a base pricing field, and
- ``off_peak_pricing`` is deliberately kept off the shared entry, so a
- deployment spelling out only its off-peak schedule would otherwise
- never receive the discount. The backend model's entire canonical cost
- map entry is copied, field by field, so threshold, tiered,
- service-tier, cache, character, and per-second rates as well as
- companion billing fields like ``web_search_billing_unit`` and the
- regional uplift multipliers all carry over, and peak-hour billing
- through the deployment entry matches the shared backend entry exactly.
- The raw ``litellm.model_cost`` entry is the copy source rather than
- ``get_model_info``'s view of it, since that view synthesizes zero flat
- token rates for backends without one and storing those would mark a
- tiered-only backend explicitly priced free. Values are deep-copied to
- keep the builtin entry isolated. User-specified fields always win;
- no-op when any base pricing field is already set or the backend model
- has no canonical entry.
- """
- if not model_info.get("off_peak_pricing"):
- return
- if any(
- model_info.get(field) is not None
- for field in ("input_cost_per_token", "input_cost_per_second", "cost_per_second", "tiered_pricing")
- ):
- return
- try:
- backend_info: Final = litellm.get_model_info(model=backend_model, custom_llm_provider=custom_llm_provider)
- except Exception: # noqa: BLE001 # get_model_info raises plain Exception for an unmapped backend model
- return
- backend_entry: Final = litellm.model_cost.get(backend_info.get("key") or "")
- if not isinstance(backend_entry, dict):
- return
- for field, backend_value in backend_entry.items():
- if model_info.get(field) is not None or backend_value is None:
- continue
- model_info[field] = copy.deepcopy(backend_value)
-
- @staticmethod
- def _inherit_builtin_tiered_output_rate(
- model_info: dict, backend_model: str, custom_llm_provider: str | None
- ) -> None:
- """Fill a missing entry-level output rate on a deployment entry whose tier
- table omits one, from the backend model's built-in cost map entry.
-
- A deployment's custom pricing is registered as its own standalone
- ``litellm.model_cost`` entry holding only the supplied fields, and the
- tiered-cost output fallback reads that same entry, so a tier table that
- spells out only input-side rates would bill every completion at 0.
-
- A user-specified ``output_cost_per_token`` always wins. No-op without a
- tier table, when every tier declares its own output rate, or when the
- backend model has no canonical entry or no flat output rate:
- ``get_model_info`` synthesizes a zero for tiered-only backends, and
- storing that zero would mark the deployment as explicitly priced free.
- """
- tiers: Final = model_info.get("tiered_pricing")
- if not isinstance(tiers, list) or not tiers:
- return
- if model_info.get("output_cost_per_token") is not None:
- return
- if all(isinstance(tier, dict) and "output_cost_per_token" in tier for tier in tiers):
- return
- try:
- backend_info: Final = litellm.get_model_info(model=backend_model, custom_llm_provider=custom_llm_provider)
- except Exception: # noqa: BLE001 # get_model_info raises plain Exception for an unmapped backend model
- return
- backend_rate: Final = backend_info.get("output_cost_per_token")
- if backend_rate:
- model_info["output_cost_per_token"] = backend_rate
-
- def _create_deployment(
- self,
- deployment_info: dict,
- _model_name: str,
- _litellm_params: dict,
- _model_info: dict,
- *,
- declared_id: str | None = None,
- duplicate_ids: frozenset[str] = frozenset(),
- ) -> Deployment | None:
- """
- Create a deployment object and add it to the model list
-
- If the deployment is not active for the current environment, it is ignored
-
- Returns:
- - Deployment: The deployment object
- - None: If the deployment is not active for the current environment (if 'supported_environments' is set in litellm_params)
- """
- try:
- config_sourced: Final = _model_info.get("db_model") is not True
- identity_error: Final = (
- ptu_identity_error(
- declared_id=declared_id,
- taken=declared_id in duplicate_ids,
- current_id=_model_info.get("id"),
- model_name=_model_name,
- )
- if config_sourced and ptu_terms(_model_info) is not None
- else None
- )
- ptu_error: Final = (
- (ptu_config_error(_model_info, model_name=_model_name) or identity_error) if config_sourced else None
- )
- declares_split: Final = declares_ptu_shares(_model_info)
- if ptu_error is not None and (declares_split or is_ptu_cost_attribution_enabled()):
- raise ValueError(ptu_error)
- access_windows_error: Final = access_windows_config_error(_model_info, model_name=_model_name)
- if access_windows_error is not None:
- raise ValueError(access_windows_error)
- capacity_warning: Final = (
- ptu_capacity_warning(
- _model_name,
- MappingProxyType(
- { # pyright: ignore[reportUnknownArgumentType] # router deployment dicts are untyped
- "model_info": _model_info,
- "litellm_params": _litellm_params,
- }
- ),
- )
- if is_ptu_cost_attribution_enabled()
- else None
- )
- if capacity_warning is not None:
- verbose_router_logger.warning(capacity_warning)
- zeroed_pricing: Final = zeroed_ptu_pricing(_model_info, _litellm_params) if config_sourced else None
- merged_params: Final[Mapping[str, object]] = (
- _litellm_params if zeroed_pricing is None else MappingProxyType({**_litellm_params, **zeroed_pricing})
- )
- litellm_params: Final[LiteLLM_Params] = validate_litellm_params_strictly(
- LiteLLM_Params, dict(**merged_params)
- )
- declared_params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(_litellm_params)
- warn_on_provider_credential_mismatch(model_name=_model_name, litellm_params=declared_params)
- warn_on_ignored_model_capability_flags(model_name=_model_name, litellm_params=declared_params)
- warn_on_unenforced_endpoint_flag(model_name=_model_name, model_info=_model_info)
- deployment = without_model_capability_flags(
- Deployment(
- **deployment_info,
- model_name=_model_name,
- litellm_params=litellm_params,
- model_info=_model_info,
- )
- )
- Router._register_deployment_pricing(deployment)
-
- ## Check if LLM Deployment is allowed for this deployment
- if self.deployment_is_active_for_environment(deployment=deployment) is not True:
- verbose_router_logger.warning(
- "Ignoring deployment %s as it is not active for environment %s",
- deployment.model_name,
- deployment.model_info["supported_environments"],
- )
- return None
-
- # Validate tag_regex patterns BEFORE adding the deployment so we never
- # have partially-initialised router state if a pattern is invalid.
- _tag_regex: Final = deployment.litellm_params.get("tag_regex") or []
- for pattern in _tag_regex:
- try:
- re.compile(pattern)
- except re.error as exc:
- raise ValueError(
- f"Invalid regex in tag_regex for model '{deployment.model_name}': {pattern!r} — {exc}"
- ) from exc
-
- deployment = self._add_deployment(deployment=with_admin_api_key_secret_reference(deployment))
-
- model: Final = deployment.to_json(exclude_none=True)
-
- self._add_model_to_list_and_index_map(model=model, model_id=deployment.model_info.id)
- return deployment
- except Exception as e:
- if self.ignore_invalid_deployments:
- if isinstance(e, litellm.BadRequestError):
- self._provider_unresolved_deployments = (
- *self._provider_unresolved_deployments,
- partial(
- self._create_deployment,
- deployment_info=deployment_info,
- _model_name=_model_name,
- _litellm_params=_litellm_params,
- _model_info=_model_info,
- declared_id=declared_id,
- duplicate_ids=duplicate_ids,
- ),
- )
- verbose_router_logger.exception(
- "Error creating deployment: %s, ignoring and continuing with other deployments.", e
- )
- return None
- else:
- raise e
-
- def _is_auto_router_deployment(self, litellm_params: LiteLLM_Params) -> bool:
- """
- Check if the deployment is an auto-router deployment (semantic router).
-
- Returns True if the litellm_params model starts with "auto_router/"
- but NOT "auto_router/complexity_router" or "auto_router/adaptive_router"
- (which use the complexity-router and adaptive-router strategies).
- """
- return classify_strategy_router_model(litellm_params.model) == "semantic"
-
- @staticmethod
- def _deployment_tags(deployment: Deployment) -> tuple[str, ...]:
- """Deployment tags used to disambiguate strategy registries keyed by model_name."""
- return tuple(deployment.litellm_params.tags or ())
-
- def init_auto_router_deployment(self, deployment: Deployment):
- """
- Initialize the auto-router deployment.
-
- This will initialize the auto-router and add it to the auto-routers dictionary.
- """
- from litellm.router_strategy.auto_router.auto_router import AutoRouter
-
- auto_router_config_path: Final[str | None] = deployment.litellm_params.auto_router_config_path
- auto_router_config: Final[str | None] = deployment.litellm_params.auto_router_config
- if auto_router_config_path is None and auto_router_config is None:
- raise ValueError(
- "auto_router_config_path or auto_router_config is required for auto-router deployments. Please set it in the litellm_params"
- )
-
- default_model: Final[str | None] = deployment.litellm_params.auto_router_default_model
- if default_model is None:
- raise ValueError(
- "auto_router_default_model is required for auto-router deployments. Please set it in the litellm_params"
- )
-
- embedding_model: Final[str | None] = deployment.litellm_params.auto_router_embedding_model
- if embedding_model is None:
- raise ValueError(
- "auto_router_embedding_model is required for auto-router deployments. Please set it in the litellm_params"
- )
-
- max_input_chars: Final[int | None] = deployment.litellm_params.auto_router_max_input_chars
-
- autor_router: Final[AutoRouter] = AutoRouter(
- model_name=deployment.model_name,
- auto_router_config_path=auto_router_config_path,
- auto_router_config=auto_router_config,
- default_model=default_model,
- embedding_model=embedding_model,
- ctx=self._core_ctx,
- max_input_chars=(max_input_chars if max_input_chars is not None else DEFAULT_AUTO_ROUTER_MAX_INPUT_CHARS),
- )
- self._register_pre_routing_strategy(
- registry=self._core_auto_routers,
- deployment=deployment,
- strategy=autor_router,
- strategy_label="Auto-router",
- )
-
- def _is_complexity_router_deployment(self, litellm_params: LiteLLM_Params) -> bool:
- """
- Check if the deployment is a complexity-router deployment.
-
- Returns True if the litellm_params model starts with "auto_router/complexity_router"
- """
- return classify_strategy_router_model(litellm_params.model) == "complexity"
-
- def _core_config_deployments(self) -> Iterator[Mapping[str, object]]:
- """The model_list rows that came from config.yaml rather than the DB (``model_info.db_model`` unset)."""
- for deployment in self._core_model_list:
- if not isinstance(deployment, Mapping):
- continue
- model_info = deployment.get("model_info")
- if not (isinstance(model_info, Mapping) and model_info.get("db_model")):
- yield deployment
-
- def auto_router_capability_violation(self, capability: GatedAutoRouterCapability) -> str | None:
- """
- Why one more router claiming ``capability`` cannot join this router, or None when it can.
-
- Judged against every deployment currently on the model_list; an upsert pops the row being
- edited first, so an edit of an existing gated router keeps its own slot. The limit is
- resolved on every call through ``auto_router_capability_limit``; unset means unlimited,
- which is the SDK default, and the proxy injects a resolver backed by its license.
- """
- limit: Final = self.auto_router_capability_limit() if self.auto_router_capability_limit is not None else None
- others: Final = count_capability_routers(
- (deployment for deployment in self._core_model_list if isinstance(deployment, Mapping)),
- capability=capability,
- )
- return capability_limit_violation(capability=capability, held=others + 1, limit=limit)
-
- def init_complexity_router_deployment(self, deployment: Deployment):
- """
- Initialize the complexity-router deployment.
-
- This will initialize the complexity-router and add it to the complexity-routers dictionary.
- """
- # Import here to avoid circular imports — ComplexityRouter is a CustomLogger
- # subclass that imports litellm internals which depend on router.py.
- # This matches the AutoRouter pattern in init_auto_router_deployment above.
- from litellm.router_strategy.complexity_router.complexity_router import (
- ComplexityRouter,
- )
- from litellm.router_strategy.complexity_router.config import (
- ComplexityRouterConfig,
- )
-
- complexity_router_config: Final[dict | None] = deployment.litellm_params.complexity_router_config
- for capability in claimed_capabilities(complexity_router_config):
- if (limit_violation := self.auto_router_capability_violation(capability)) is not None:
- raise ValueError(limit_violation)
-
- default_model: Final = (
- ComplexityRouterConfig.model_validate(complexity_router_config).resolve_default_model(
- deployment.litellm_params.complexity_router_default_model
- )
- if complexity_router_config
- else deployment.litellm_params.complexity_router_default_model
- )
-
- if default_model is None:
- raise ValueError(
- "complexity_router_default_model is required for complexity-router deployments, "
- "or configure tiers in complexity_router_config. Please set it in the litellm_params"
- )
-
- complexity_router: Final[ComplexityRouter] = ComplexityRouter(
- model_name=deployment.model_name,
- default_model=default_model,
- ctx=self._core_ctx,
- complexity_router_config=complexity_router_config,
- )
- self._register_pre_routing_strategy(
- registry=self._core_complexity_routers,
- deployment=deployment,
- strategy=complexity_router,
- strategy_label="Complexity-router",
- )
- if complexity_router._uses_deployment_pin:
- self._ensure_deployment_affinity_callback()
-
- def _is_adaptive_router_deployment(self, litellm_params: LiteLLM_Params) -> bool:
- """True when this deployment opts in via the `auto_router/adaptive_router` model prefix."""
- return classify_strategy_router_model(litellm_params.model) == "adaptive"
-
- def _deployment_participates_in_adaptive_routing(self, litellm_params: LiteLLM_Params) -> bool:
- """True when this deployment owns an `adaptive_routers` entry once finalized:
- a dedicated adaptive router, or a complexity router whose config enables the
- adaptive companion. Mirrors the two arms of
- `_finalize_adaptive_router_if_configured`, which is the registry's only writer."""
- if self._is_adaptive_router_deployment(litellm_params=litellm_params):
- return True
- if not self._is_complexity_router_deployment(litellm_params=litellm_params):
- return False
- config: Final = litellm_params.complexity_router_config
- if not config:
- return False
- adaptive_flag: Final[object] = config.get("adaptive")
- return bool(adaptive_flag)
-
- @staticmethod
- def _has_registered_strategy(
- registry: dict[str, list[TaggedPreRoutingStrategy[_PreRoutingStrategyT]]],
- model_name: str,
- tags: tuple[str, ...],
- ) -> bool:
- """True when a strategy for this (model_name, tags) pair is already registered."""
- return any(existing.tags == tags for existing in registry.get(model_name, []))
-
- def _register_pre_routing_strategy(
- self,
- registry: dict[str, list[TaggedPreRoutingStrategy[_PreRoutingStrategyT]]],
- deployment: Deployment,
- strategy: _PreRoutingStrategyT,
- strategy_label: str,
- ) -> None:
- """
- Register `strategy` under `deployment.model_name`, scoped by its tags.
- Reusing a `model_name` is allowed when tags differ; a repeat of the same
- (model_name, tags) pair is a misconfiguration and is rejected.
- """
- tags: Final = self._deployment_tags(deployment)
- if self._has_registered_strategy(registry, deployment.model_name, tags):
- raise ValueError(
- f"{strategy_label} deployment {deployment.model_name} with tags {list(tags)} already exists. "
- "Please use a different model name or set different tags."
- )
- registry[deployment.model_name] = [
- *registry.get(deployment.model_name, []),
- TaggedPreRoutingStrategy(
- tags=tags,
- strategy=strategy,
- definition_fingerprint=self._routing_definition_fingerprint(deployment),
- deployment=deployment,
- ),
- ]
-
- @staticmethod
- def _routing_definition_fingerprint(deployment: Deployment) -> str | None:
- """Identify configured routing fields, excluding external files and live strategy state."""
- try:
- definition: Final = json.dumps(
- deployment.litellm_params.model_dump(
- include=STRATEGY_ROUTER_PARAM_FIELDS | {"model", "tags"}, exclude_none=True, warnings=False
- ),
- sort_keys=True,
- default=Router._json_default_stable_id,
- )
- except (TypeError, ValueError):
- return None
- return Router._core_generate_model_id(deployment.model_name, {"routing_definition": definition})
-
- @staticmethod
- def _unregister_pre_routing_strategy(
- registry: dict[str, list[TaggedPreRoutingStrategy[_PreRoutingStrategyT]]],
- model_name: str,
- tags: tuple[str, ...],
- ) -> bool:
- """Drop the strategy registered for this exact (model_name, tags) pair, leaving
- strategies registered under the same name with different tags in place. Returns
- whether anything was actually dropped."""
- existing: Final = registry.get(model_name, [])
- remaining: Final = [entry for entry in existing if entry.tags != tags]
- if len(remaining) == len(existing):
- return False
- if remaining:
- registry[model_name] = remaining
- else:
- registry.pop(model_name, None)
- return True
-
- def _unregister_pre_routing_strategy_for_deployment(self, deployment: Deployment) -> None:
- """
- Release the pre-routing strategy a deployment holds, so removing it from the
- model_list also frees its (model_name, tags) slot.
-
- Without this, re-adding the deployment (an edit arriving via upsert_deployment,
- or a router recreated under a name that was deleted earlier) hits the
- "already exists" guard in `_register_pre_routing_strategy`, which
- `ignore_invalid_deployments` swallows - the deployment then silently never
- makes it back into the model_list.
-
- Released from every registry rather than the first match, because registration is
- one-to-many: a complexity router configured with `adaptive` is also registered in
- `adaptive_routers` under the same (model_name, tags) by the deferred finalize pass.
- Guarded on the auto_router/ prefix so removing a *regular* deployment can't evict a
- router that merely shares its model_name.
- """
- if not deployment.litellm_params.model.startswith("auto_router/"):
- return
- model_name: Final = deployment.model_name
- tags: Final = self._deployment_tags(deployment)
- for registry in (self._core_auto_routers, self._core_complexity_routers, self._core_quality_routers):
- self._unregister_pre_routing_strategy(registry, model_name, tags)
- if self._unregister_pre_routing_strategy(self._core_adaptive_routers, model_name, tags):
- self._sync_adaptive_router_hooks()
-
- def _finalize_adaptive_router_if_configured(self) -> None:
- """Locate every adaptive-router deployment in the finalized model_list and
- build an AdaptiveRouter for each. Safe no-op when none are configured.
- Idempotent: skips any deployment whose (model_name, tags) pair is already
- initialized, so hot-reloads don't rebuild routers that would lose state."""
- for entry in self._core_model_list or []:
- lp = entry.get("litellm_params") if isinstance(entry, dict) else entry.litellm_params
- lp_model = (lp.get("model") if isinstance(lp, dict) else lp.model) if lp else None
- if not (lp_model and lp_model.startswith("auto_router/adaptive_router")):
- continue
- model_name = entry.get("model_name") if isinstance(entry, dict) else entry.model_name
- if not model_name or not lp:
- continue
- deployment = Deployment(
- model_name=model_name,
- litellm_params=(lp if not isinstance(lp, dict) else LiteLLM_Params.model_validate(lp)),
- model_info=(entry.get("model_info") if isinstance(entry, dict) else entry.model_info),
- )
- if self._has_registered_strategy(
- self._core_adaptive_routers, model_name, self._deployment_tags(deployment)
- ):
- continue
- self.init_adaptive_router_deployment(deployment=deployment)
-
- for model_name, tagged_complexity_routers in self._core_complexity_routers.items():
- for tagged in tagged_complexity_routers:
- complexity_router = tagged.strategy
- if not complexity_router.config.adaptive:
- continue
- if self._has_registered_strategy(self._core_adaptive_routers, model_name, tagged.tags):
- continue
- adaptive_router: AdaptiveRouter | None = complexity_router._ensure_adaptive_router()
- if adaptive_router is not None:
- self._core_adaptive_routers[model_name] = [
- *self._core_adaptive_routers.get(model_name, []),
- TaggedPreRoutingStrategy(
- tags=tagged.tags,
- strategy=adaptive_router,
- definition_fingerprint=tagged.definition_fingerprint,
- deployment=tagged.deployment,
- ),
- ]
-
- self._sync_adaptive_router_hooks()
-
- def _sync_adaptive_router_hooks(self) -> None:
- """Rebuild the AdaptiveRouterPostCallHook set so it is exactly one hook per
- currently registered adaptive router. Run at every point the adaptive registry
- changes, otherwise a released router keeps recording turns through its hook."""
- from litellm.router_strategy.adaptive_router.hooks import (
- AdaptiveRouterPostCallHook,
- AdaptiveRouterResponseHeaderHook,
- )
-
- for callback in litellm.logging_callback_manager.get_custom_loggers_for_type(AdaptiveRouterPostCallHook):
- litellm.logging_callback_manager.remove_callback_from_all_lists(callback)
- for tagged_adaptive_routers in self._core_adaptive_routers.values():
- for tagged in tagged_adaptive_routers:
- litellm.logging_callback_manager.add_litellm_callback(
- AdaptiveRouterPostCallHook(adaptive_router=tagged.strategy)
- )
- if self._core_adaptive_routers:
- litellm.logging_callback_manager.add_litellm_callback(AdaptiveRouterResponseHeaderHook())
-
- def init_adaptive_router_deployment(self, deployment: Deployment) -> None:
- """
- Build an AdaptiveRouter instance for this deployment and register its
- post-call hook. Multiple adaptive routers can coexist on a single Router,
- keyed by `deployment.model_name`.
-
- `model_to_prefs` and `model_to_cost` are derived from the OTHER models
- already registered in `self._core_model_list` whose `model_name` appears in
- `available_models`. Models not yet registered fall back to defaults.
- """
- # Local import: AdaptiveRouter -> hooks -> classifier all import litellm
- # internals which transitively import this module. (AGENTS.md exception clause.)
- from litellm.router_strategy.adaptive_router.adaptive_router import (
- AdaptiveRouter,
- )
- from litellm.router_strategy.adaptive_router.hooks import (
- AdaptiveRouterPostCallHook,
- )
- from litellm.types.router import (
- AdaptiveRouterConfig,
- AdaptiveRouterPreferences,
- )
-
- raw_config: Final = deployment.litellm_params.adaptive_router_config
- if raw_config is None:
- raise ValueError("adaptive_router_config is required for adaptive-router deployments.")
-
- config: Final = AdaptiveRouterConfig(**raw_config)
-
- model_to_prefs: Final[dict[str, AdaptiveRouterPreferences]] = {}
- model_to_cost: Final[dict[str, float]] = {}
- # O(k) via the name→indices map: only touch deployments whose name
- # is listed in `available_models`, instead of scanning model_list.
- for name in config.available_models:
- deployments = self._core_deployments_named(name)
- if not deployments:
- continue
- d = deployments[0]
- mi_dict: dict[str, Any] = d.get("model_info") or {}
- prefs_raw = mi_dict.get("adaptive_router_preferences")
- if prefs_raw is not None:
- model_to_prefs[name] = AdaptiveRouterPreferences(**prefs_raw)
-
- # model_info is the conventional pricing location elsewhere in LiteLLM; litellm_params wins if set.
- cost = d.get("litellm_params", {}).get("input_cost_per_token")
- if cost is None:
- cost = mi_dict.get("input_cost_per_token")
- if cost is not None:
- model_to_cost[name] = float(cost)
-
- adaptive_router: Final = AdaptiveRouter(
- router_name=deployment.model_name,
- config=config,
- model_to_prefs=model_to_prefs,
- model_to_cost=model_to_cost,
- )
- self._register_pre_routing_strategy(
- registry=self._core_adaptive_routers,
- deployment=deployment,
- strategy=adaptive_router,
- strategy_label="Adaptive-router",
- )
- litellm.logging_callback_manager.add_litellm_callback(
- AdaptiveRouterPostCallHook(adaptive_router=adaptive_router)
- )
- verbose_router_logger.info(
- "AdaptiveRouter[%s] initialized with %d models",
- deployment.model_name,
- len(config.available_models),
- )
-
- def _is_quality_router_deployment(self, litellm_params: LiteLLM_Params) -> bool:
- """
- Check if the deployment is a quality-router deployment.
-
- Returns True if the litellm_params model starts with "auto_router/quality_router".
- """
- return classify_strategy_router_model(litellm_params.model) == "quality"
-
- def init_quality_router_deployment(self, deployment: Deployment):
- """
- Initialize the quality-router deployment.
-
- Resolves the default model from either `quality_router_default_model` or
- `quality_router_config["default_model"]`, then instantiates the
- QualityRouter and stores it in `self._core_quality_routers`.
- """
- # Import here to mirror the AutoRouter / ComplexityRouter init pattern
- # and avoid circular imports.
- from litellm.router_strategy.quality_router.quality_router import (
- QualityRouter,
- )
-
- quality_router_config: Final[dict | None] = deployment.litellm_params.quality_router_config
-
- default_model: str | None = deployment.litellm_params.quality_router_default_model
- if default_model is None and quality_router_config:
- default_model = quality_router_config.get("default_model")
-
- if default_model is None:
- raise ValueError(
- "quality_router_default_model is required for quality-router deployments, "
- "or set default_model in quality_router_config. Please configure it in the litellm_params"
- )
-
- quality_router: Final[QualityRouter] = QualityRouter(
- model_name=deployment.model_name,
- default_model=default_model,
- ctx=self._core_ctx,
- quality_router_config=quality_router_config,
- )
- self._register_pre_routing_strategy(
- registry=self._core_quality_routers,
- deployment=deployment,
- strategy=quality_router,
- strategy_label="Quality-router",
- )
-
- def deployment_is_active_for_environment(self, deployment: Deployment) -> bool:
- """
- Function to check if a llm deployment is active for a given environment. Allows using the same config.yaml across multople environments
-
- Requires `LITELLM_ENVIRONMENT` to be set in .env. Valid values for environment:
- - development
- - staging
- - production
-
- Raises:
- - ValueError: If LITELLM_ENVIRONMENT is not set in .env or not one of the valid values
- - ValueError: If supported_environments is not set in model_info or not one of the valid values
- """
- return model_info_is_active_for_environment(model_info=deployment.model_info)
-
- def set_model_list(self, model_list: list):
- original_model_list: Final = copy.deepcopy(model_list)
- self._discovered_model_info_cache.flush_cache()
- with self._deployment_index_lock:
- self._core_model_list = []
- self.model_id_to_deployment_index_map = {} # Reset the index
- self._core_model_name_to_deployment_indices = {} # Reset the model_name index
- self._core_team_model_to_deployment_indices = {} # Reset the team_model index
- self._core_team_pattern_routers = {}
- self._core_team_public_model_names = frozenset()
- # Reset per-strategy router registries so hot-reload doesn't leave
- # stale routers pointing at the old model_list.
- self._core_quality_routers = {}
- self._core_complexity_routers = {}
- self._core_auto_routers = {}
- self._provider_unresolved_deployments = ()
- self._invalidate_model_group_info_cache()
- self._invalidate_access_groups_cache()
- # we add api_base/api_key each model so load balancing between azure/gpt on api_base1 and api_base2 works
-
- declared_ids: Final = tuple(
- str(entry["model_info"]["id"])
- for entry in original_model_list
- if isinstance(entry.get("model_info"), dict) and entry["model_info"].get("id") is not None
- )
- duplicate_ids: Final = frozenset(model_id for model_id in declared_ids if declared_ids.count(model_id) > 1)
-
- ptu_declared: Final = tuple(
- str(entry.get("model_name"))
- for entry in original_model_list
- if isinstance(entry.get("model_info"), dict)
- and entry["model_info"].get("db_model") is not True
- and declares_ptu(entry["model_info"])
- )
- if ptu_declared and not is_ptu_cost_attribution_enabled():
- verbose_router_logger.warning(
- "PTU fields are set on config.yaml deployment(s) %s, but PTU cost attribution is disabled, so no "
- "flat cost accrues and this traffic is billed per token. Set %s=True to enable it",
- ", ".join(ptu_declared),
- PTU_COST_ATTRIBUTION_ENV_VAR,
- )
-
- for model in original_model_list:
- _model_name = model.pop("model_name")
- _litellm_params = model.pop("litellm_params")
- ## check if litellm params in os.environ
- if isinstance(_litellm_params, dict):
- for k, v in _litellm_params.items():
- if isinstance(v, str) and v.startswith("os.environ/") and not holds_secret_pointer(k):
- _litellm_params[k] = get_secret(v)
-
- _model_info: dict = model.pop("model_info", {})
-
- declared_id = None if _model_info.get("id") is None else str(_model_info["id"])
-
- # check if model info has id
- if "id" not in _model_info:
- _id = self._core_generate_model_id(_model_name, _litellm_params)
- _model_info["id"] = _id
-
- if _litellm_params.get("organization", None) is not None and isinstance(
- _litellm_params["organization"], list
- ): # Addresses https://github.com/BerriAI/litellm/issues/3949
- for org in _litellm_params["organization"]:
- _litellm_params["organization"] = org
- self._create_deployment(
- deployment_info=model,
- _model_name=_model_name,
- _litellm_params=_litellm_params,
- _model_info=_model_info,
- declared_id=declared_id,
- duplicate_ids=duplicate_ids,
- )
- else:
- self._create_deployment(
- deployment_info=model,
- _model_name=_model_name,
- _litellm_params=_litellm_params,
- _model_info=_model_info,
- declared_id=declared_id,
- duplicate_ids=duplicate_ids,
- )
-
- verbose_router_logger.debug("\nInitialized Model List %s", self._core_get_model_names())
- self._core_model_names = {m["model_name"] for m in model_list}
-
- # Note: model_name_to_deployment_indices is already built incrementally
- # by _create_deployment -> _add_model_to_list_and_index_map
-
- # Deferred: build the AdaptiveRouter strategy now that all underlying
- # deployments have been registered.
- self._finalize_adaptive_router_if_configured()
-
- def _add_deployment(self, deployment: Deployment) -> Deployment:
- import os
-
- #### VALIDATE MODEL ########
- # Check if this is a prompt management model before validating as LLM provider
- litellm_model: Final = deployment.litellm_params.model
- if isinstance(deployment.litellm_params.drop_params, str):
- verbose_router_logger.warning(
- "model=%s drop_params=%r is not a flag value, treating it as unset",
- deployment.model_name,
- deployment.litellm_params.drop_params,
- )
- is_prompt_management_model = False
-
- if "/" in litellm_model:
- split_litellm_model: Final = litellm_model.split("/")[0]
- if split_litellm_model in litellm._known_custom_logger_compatible_callbacks:
- is_prompt_management_model = True
-
- if is_prompt_management_model:
- # For prompt management models, skip LLM provider validation
- # The actual model will be resolved at runtime from the prompt file
- _model = litellm_model
- custom_llm_provider = None
- dynamic_api_key = None
- api_base = None
- elif declared_authenticating_provider(litellm_model, deployment.litellm_params.custom_llm_provider) is not None:
- _model, custom_llm_provider = ( # rebind-ok: each branch resolves the deployment provider
- self._model_group_llm_provider(deployment.litellm_params)
- )
- else:
- # check if model provider in supported providers
- (
- _model,
- custom_llm_provider,
- dynamic_api_key,
- api_base,
- ) = litellm.get_llm_provider(
- model=deployment.litellm_params.model,
- custom_llm_provider=deployment.litellm_params.get("custom_llm_provider", None),
- api_base=deployment.litellm_params.api_base,
- litellm_params=deployment.litellm_params,
- )
- # done reading model["litellm_params"]
- # Check if provider is supported: either in enum or JSON-configured
- if (
- custom_llm_provider not in litellm.provider_list
- and not JSONProviderRegistry.exists(custom_llm_provider)
- and not is_registered_custom_provider(custom_llm_provider)
- ):
- raise Exception(f"Unsupported provider - {custom_llm_provider}")
-
- #### DEPLOYMENT NAMES INIT ########
- self._core_deployment_names.append(deployment.litellm_params.model)
- ############ Users can either pass tpm/rpm as a litellm_param or a router param ###########
- # for get_available_deployment, we use the litellm_param["rpm"]
- # in this snippet we also set rpm to be a litellm_param
- if deployment.litellm_params.rpm is None and getattr(deployment, "rpm", None) is not None:
- deployment.litellm_params.rpm = getattr(deployment, "rpm")
-
- if deployment.litellm_params.tpm is None and getattr(deployment, "tpm", None) is not None:
- deployment.litellm_params.tpm = getattr(deployment, "tpm")
-
- # Check if user is trying to use model_name == "*"
- # this is a catch all model for their specific api key
- # if deployment.model_name == "*":
- # if deployment.litellm_params.model == "*":
- # # user wants to pass through all requests to litellm.acompletion for unknown deployments
- # self._core_router_general_settings.pass_through_all_models = True
- # else:
- # self._core_default_deployment = deployment.to_json(exclude_none=True)
- # Check if user is using provider specific wildcard routing
- # example model_name = "databricks/*" or model_name = "anthropic/*"
- if "*" in deployment.model_name:
- # store this as a regex pattern - all deployments matching this pattern will be sent to this deployment
- # Store deployment.model_name as a regex pattern
- self._core_pattern_router.add_pattern(deployment.model_name, deployment.to_json(exclude_none=True))
- if deployment.model_info.id:
- self._core_provider_default_deployment_ids.append(deployment.model_info.id)
-
- _team_id: Final = deployment.model_info.get("team_id")
- _team_public_model_name: Final[str | None] = deployment.model_info.get("team_public_model_name")
- if _team_id is not None and _team_public_model_name is not None and "*" in _team_public_model_name:
- if _team_id not in self._core_team_pattern_routers:
- self._core_team_pattern_routers[_team_id] = PatternMatchRouter()
- self._core_team_pattern_routers[_team_id].add_pattern(
- _team_public_model_name, deployment.to_json(exclude_none=True)
- )
-
- # Azure GPT-Vision Enhancements, users can pass os.environ/
- data_sources: Final = deployment.litellm_params.get("dataSources", []) or []
-
- for data_source in data_sources:
- params = data_source.get("parameters", {})
- for param_key in ["endpoint", "key"]:
- # if endpoint or key set for Azure GPT Vision Enhancements, check if it's an env var
- if param_key in params and params[param_key].startswith("os.environ/"):
- env_name = params[param_key].replace("os.environ/", "")
- params[param_key] = os.environ.get(env_name, "")
-
- # # init OpenAI, Azure clients
- # InitalizeOpenAISDKClient.set_client(
- # litellm_router_instance=self, model=deployment.to_json(exclude_none=True)
- # )
-
- if custom_llm_provider is not None:
- self._initialize_deployment_for_pass_through(
- deployment=deployment,
- custom_llm_provider=custom_llm_provider,
- )
-
- #########################################################
- # Check if this is an auto-router deployment
- #########################################################
- if self._is_auto_router_deployment(litellm_params=deployment.litellm_params):
- self.init_auto_router_deployment(deployment=deployment)
-
- #########################################################
- # Check if this is a complexity-router deployment
- #########################################################
- if self._is_complexity_router_deployment(litellm_params=deployment.litellm_params):
- self.init_complexity_router_deployment(deployment=deployment)
-
- # NOTE: adaptive-router deployments are deferred to the end of
- # set_model_list() because their init needs visibility into the OTHER
- # deployments listed in `available_models` (which may not yet have
- # been processed when this one is created).
- #########################################################
- # Check if this is a quality-router deployment
- #########################################################
- if self._is_quality_router_deployment(litellm_params=deployment.litellm_params):
- self.init_quality_router_deployment(deployment=deployment)
-
- return deployment
-
- def _initialize_deployment_for_pass_through(self, deployment: Deployment, custom_llm_provider: str):
- """
- Optional: Register vertex credentials for pass-through endpoints if `deployment.litellm_params.use_in_pass_through` is True
-
- Other providers need no registration here: PassthroughEndpointRouter.get_credentials resolves their credentials per-request from the live router deployments
- """
- if deployment.litellm_params.use_in_pass_through is not True:
- return
- if custom_llm_provider != "vertex_ai":
- return
- credential_name: Final = deployment.litellm_params.litellm_credential_name
- credential_values: Final = (
- CredentialAccessor.get_credential_values(credential_name) if credential_name is not None else {}
- )
- from litellm.types.llms.vertex_ai import VERTEX_CREDENTIALS_TYPES
-
- vertex_project: Final = (
- cast("str | None", credential_values.get("vertex_project")) # cast-ok: vertex credential values are str
- or deployment.litellm_params.vertex_project
- )
- vertex_location: Final = (
- cast("str | None", credential_values.get("vertex_location")) # cast-ok: vertex credential values are str
- or deployment.litellm_params.vertex_location
- )
- vertex_credentials: Final = (
- cast( # cast-ok: vertex_credentials holds the typed credential union
- "VERTEX_CREDENTIALS_TYPES | None", credential_values.get("vertex_credentials")
- )
- or deployment.litellm_params.vertex_credentials
- )
-
- if vertex_project is None or vertex_location is None:
- raise ValueError(
- "vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints."
- )
- router_host().add_vertex_pass_through_credentials(
- project_id=vertex_project,
- location=vertex_location,
- vertex_credentials=vertex_credentials,
- )
-
- def add_deployment(self, deployment: Deployment) -> Deployment | None:
- """
- Parameters:
- - deployment: Deployment - the deployment to be added to the Router
-
- Returns:
- - The added deployment
- - OR None (if deployment already exists)
- """
- # check if deployment already exists
-
- _deployment_model_id: Final = deployment.model_info.id
- if _deployment_model_id and self._core_has_model_id(_deployment_model_id):
- return None
-
- declared_params: Final = deployment.litellm_params.model_dump(exclude_none=True)
- warn_on_provider_credential_mismatch(model_name=deployment.model_name, litellm_params=declared_params)
- warn_on_ignored_model_capability_flags(model_name=deployment.model_name, litellm_params=declared_params)
- warn_on_unenforced_endpoint_flag(
- model_name=deployment.model_name, model_info=deployment.model_info.model_dump()
- )
- served: Final = with_admin_api_key_secret_reference(without_model_capability_flags(deployment))
-
- # add to model list
- _deployment: Final = served.to_json(exclude_none=True)
- # initialize client
- self._add_deployment(deployment=served)
-
- Router._register_deployment_pricing(deployment=served)
-
- # add to model names
- self._add_model_to_list_and_index_map(model=_deployment, model_id=served.model_info.id)
- self._core_model_names.add(served.model_name)
- self._sync_deployment_budget_config(deployment=served)
- return served
-
- def _update_deployment_indices_after_removal(self, model_id: str, removal_idx: int) -> None:
- """
- Helper method to update deployment indices after a deployment has been removed from model_list.
-
- Parameters:
- - model_id: str - the id of the deployment that was removed
- - removal_idx: int - the index where the deployment was removed from model_list
- """
- self._discovered_model_info_cache.delete_cache(model_id)
- # Update indices for all models after the removed one
- for deployment_id, idx in self.model_id_to_deployment_index_map.items():
- if idx > removal_idx:
- self.model_id_to_deployment_index_map[deployment_id] = idx - 1
- # Remove the deleted model from index
- if model_id in self.model_id_to_deployment_index_map:
- del self.model_id_to_deployment_index_map[model_id]
-
- # Update model_name_to_deployment_indices
- for model_name, indices in list(self._core_model_name_to_deployment_indices.items()):
- # Build new list without mutating the original
- updated_indices = []
- for idx in indices:
- if idx == removal_idx:
- # Skip the removed index
- continue
- elif idx > removal_idx:
- # Decrement indices after removal
- updated_indices.append(idx - 1)
- else:
- # Keep indices before removal unchanged
- updated_indices.append(idx)
-
- # Update or remove the entry
- if len(updated_indices) > 0:
- self._core_model_name_to_deployment_indices[model_name] = updated_indices
- else:
- del self._core_model_name_to_deployment_indices[model_name]
- self._core_model_names.discard(model_name)
-
- # Update team_model_to_deployment_indices
- for key, indices in list(self._core_team_model_to_deployment_indices.items()):
- # Build new list without mutating the original
- updated_indices = []
- for idx in indices:
- if idx == removal_idx:
- # Skip the removed index
- continue
- elif idx > removal_idx:
- # Decrement indices after removal
- updated_indices.append(idx - 1)
- else:
- # Keep indices before removal unchanged
- updated_indices.append(idx)
-
- # Update or remove the entry
- if len(updated_indices) > 0:
- self._core_team_model_to_deployment_indices[key] = updated_indices
- else:
- del self._core_team_model_to_deployment_indices[key]
- self._core_team_public_model_names = frozenset(
- public_model_name for _, public_model_name in self._core_team_model_to_deployment_indices
- )
-
- self._core_pattern_router.remove_deployment(model_id)
- for team_id in list(self._core_team_pattern_routers.keys()):
- team_pattern_router = self._core_team_pattern_routers[team_id]
- team_pattern_router.remove_deployment(model_id)
- if not team_pattern_router.patterns:
- del self._core_team_pattern_routers[team_id]
-
- def _update_team_model_index(self, model: dict, idx: int) -> None:
- """
- Helper to update team_model_to_deployment_indices for a single deployment.
-
- Parameters:
- - model: dict - the deployment to index
- - idx: int - the index in model_list
- """
- team_id: Final = (model.get("model_info") or {}).get("team_id")
- team_public_model_name: Final = (model.get("model_info") or {}).get("team_public_model_name")
- if team_id and team_public_model_name:
- key: Final = (team_id, team_public_model_name)
- self._core_team_public_model_names = self._core_team_public_model_names | frozenset(
- {team_public_model_name}
- )
- if key not in self._core_team_model_to_deployment_indices:
- self._core_team_model_to_deployment_indices[key] = []
- if idx not in self._core_team_model_to_deployment_indices[key]:
- self._core_team_model_to_deployment_indices[key].append(idx)
-
- def _add_model_to_list_and_index_map(self, model: dict, model_id: str | None = None) -> None:
- """
- Helper method to add a model to the model_list and update both indices.
-
- Parameters:
- - model: dict - the model to add to the list
- - model_id: Optional[str] - the model ID to use for indexing. If None, will try to get from model["model_info"]["id"]
- """
- with self._deployment_index_lock:
- idx: Final = len(self._core_model_list)
- self._core_model_list.append(model)
- self._invalidate_model_group_info_cache()
- self._invalidate_access_groups_cache()
-
- # Update model_id index for O(1) lookup
- if model_id is not None:
- self.model_id_to_deployment_index_map[model_id] = idx
- elif model.get("model_info", {}).get("id") is not None:
- self.model_id_to_deployment_index_map[model["model_info"]["id"]] = idx
-
- # Update model_name index for O(1) lookup
- model_name: Final = model.get("model_name")
- if model_name:
- if model_name not in self._core_model_name_to_deployment_indices:
- self._core_model_name_to_deployment_indices[model_name] = []
- self._core_model_name_to_deployment_indices[model_name].append(idx)
-
- # Update team_model index for O(1) team-scoped lookup
- self._update_team_model_index(model, idx)
-
- def _core_upsert_deployment(self, deployment: Deployment) -> Deployment | None:
- """
- Add or update deployment
- Parameters:
- - deployment: Deployment - the deployment to be added to the Router
-
- Returns:
- - The added/updated deployment
- """
- _deployment_model_id: Final = deployment.model_info.id or ""
- _deployment_on_router: Final[Deployment | None] = self._core_get_deployment(model_id=_deployment_model_id)
- served: Final = without_model_capability_flags(deployment)
- try:
- if _deployment_on_router is not None:
- # deployment with this model_id exists on the router
- if (
- deployment.model_name == _deployment_on_router.model_name
- and served.litellm_params == _deployment_on_router.litellm_params
- and deployment.model_info == _deployment_on_router.model_info
- ):
- # No need to update
- return None
-
- # if there is a new litellm param -> then update the deployment
- # remove the previous deployment
- with self._deployment_index_lock:
- removal_idx: Final = self.model_id_to_deployment_index_map.get(_deployment_model_id)
- if removal_idx is not None:
- self._core_model_list.pop(removal_idx)
- self._invalidate_model_group_info_cache()
- self._invalidate_access_groups_cache()
- self._update_deployment_indices_after_removal(
- model_id=_deployment_model_id, removal_idx=removal_idx
- )
-
- # Free the outgoing deployment's pre-routing strategy slot (keyed by the
- # OLD model_name/tags) before the re-add below re-registers it.
- self._unregister_pre_routing_strategy_for_deployment(deployment=_deployment_on_router)
-
- # if the model_id is not in router
- self.add_deployment(deployment=deployment)
- # add_deployment() builds every strategy EXCEPT the adaptive one, which
- # set_model_list() defers until the whole model_list is visible. Re-run that
- # deferred pass so an adaptive router whose slot was just released above is
- # rebuilt rather than left unregistered.
- if self._deployment_participates_in_adaptive_routing(litellm_params=deployment.litellm_params) or (
- _deployment_on_router is not None
- and self._deployment_participates_in_adaptive_routing(
- litellm_params=_deployment_on_router.litellm_params
- )
- ):
- self._finalize_adaptive_router_if_configured()
- return deployment
- except Exception as e:
- if self.ignore_invalid_deployments:
- verbose_router_logger.warning(
- "Error upserting deployment %s (id=%s): %s. Dropping it and continuing with other deployments.",
- deployment.model_name,
- deployment.model_info.id,
- e,
- )
- self._restore_deployment_after_failed_upsert(
- previous_deployment=_deployment_on_router, model_id=_deployment_model_id
- )
- return None
- else:
- raise e
-
- def _restore_deployment_after_failed_upsert(self, previous_deployment: Deployment | None, model_id: str) -> None:
- """Put a deployment back the way it was before a failed upsert popped it.
-
- A rollback re-admits state that was already serving, so it does not go through the
- capability ceiling a newcomer gets: with the ceiling tightened since the deployment first
- registered, judging the rollback would drop a serving router over an unrelated failed edit.
- """
- if previous_deployment is None or self._core_has_model_id(model_id):
- return
- limit_resolver: Final = self.auto_router_capability_limit
- self.auto_router_capability_limit = None
- try:
- self.add_deployment(deployment=previous_deployment)
- verbose_router_logger.info(
- "Restored deployment %s (id=%s); it keeps serving its previous configuration.",
- previous_deployment.model_name,
- model_id,
- )
- except Exception as restore_error: # noqa: BLE001 # best-effort restore: a second failure must not abort the reload
- verbose_router_logger.warning(
- "Could not restore previously served deployment %s (id=%s) after the failed upsert: %s",
- previous_deployment.model_name,
- model_id,
- restore_error,
- )
- finally:
- self.auto_router_capability_limit = limit_resolver
-
- @staticmethod
- def _backend_cost_map_keys(model: str, custom_llm_provider: str | None) -> tuple[str, ...]:
- """The ``litellm.model_cost`` keys a deployment's shared backend info is registered under."""
- backend_key: Final = model if custom_llm_provider is None else f"{custom_llm_provider}/{model}"
- if "responses/" in backend_key:
- return (backend_key, backend_key.replace("responses/", ""))
- return (backend_key,)
-
- @staticmethod
- def _deployment_model_cost_payload(deployment: Deployment) -> dict[str, object]: # mutable-ok: cost-map entry
- """The ``model_info`` a deployment contributes to ``litellm.model_cost``.
-
- Custom pricing lives on ``litellm_params`` rather than ``model_info``, and
- the built-in cache-pricing inheritance is derived rather than stored, so
- both are folded back in here. That keeps this reproducible from a
- deployment alone, which is what lets a refresh rebuild the same entries.
- """
- model_info: Final = dict(
- _MODEL_INFO_ADAPTER.validate_python(deployment.model_info.model_dump(exclude_none=True))
- )
- for field in CustomPricingLiteLLMParams.model_fields:
- field_value = deployment.litellm_params.get(field)
- if field_value is not None:
- model_info[field] = field_value
- backend_model: Final = Router._cost_map_backend_model(deployment)
- pricing_provider: Final = (
- deployment.model_info.pricing_provider or deployment.litellm_params.custom_llm_provider
- )
- params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(deployment.litellm_params.model_dump(exclude_none=True))
- if (
- params.get("model_id")
- and backend_model != deployment.litellm_params.model
- and (
- deployment.litellm_params.custom_llm_provider == "bedrock"
- or deployment.litellm_params.model.startswith("bedrock/")
- )
- ):
- profile_info: Final = lookup_model_info_for_registration(model=backend_model, custom_llm_provider="bedrock")
- if not isinstance(profile_info, ModelInfoLookupMiss):
- explicit_info: Final = _MODEL_INFO_ADAPTER.validate_python(model_info)
- priced_info: Final = model_info_with_pricing_overrides(profile_info, explicit_info)
- model_info.update(
- {key: value for key, value in priced_info.items() if is_server_derived_pricing_key(key)}
- )
- Router._inherit_builtin_base_rates_for_off_peak(
- model_info=model_info,
- backend_model=backend_model,
- custom_llm_provider=pricing_provider,
- )
- if model_info.get("input_cost_per_token") is not None:
- Router._inherit_builtin_cache_pricing(
- model_info=model_info,
- backend_model=backend_model,
- custom_llm_provider=pricing_provider,
- )
- Router._inherit_builtin_service_tier_pricing(
- model_info=model_info,
- backend_model=backend_model,
- custom_llm_provider=pricing_provider,
- )
- Router._inherit_builtin_tiered_output_rate(
- model_info=model_info,
- backend_model=backend_model,
- custom_llm_provider=pricing_provider,
- )
- return model_info
-
- @staticmethod
- def _register_deployment_pricing(deployment: Deployment) -> None:
- """Register a deployment's custom/inherited pricing in ``litellm.model_cost``.
-
- Takes only a ``Deployment``, so it registers pricing for a deployment that
- is never added to ``self._core_model_list`` (a per-request client-side-credential
- deployment) just as readily as one that is.
- """
- Router._register_deployment_in_model_cost(
- model_id=deployment.model_info.id,
- model_info=Router._deployment_model_cost_payload(deployment),
- model=deployment.litellm_params.model,
- custom_llm_provider=deployment.litellm_params.custom_llm_provider,
- pricing_provider=deployment.model_info.pricing_provider,
- )
-
- @staticmethod
- def _register_deployment_in_model_cost(
- *,
- model_id: str | None,
- model_info: dict, # mutable-ok: cost-map entry
- model: str,
- custom_llm_provider: str | None,
- pricing_provider: str | None = None,
- ) -> None:
- """Write a deployment's metadata into ``litellm.model_cost``.
-
- Runs when a deployment is added and again after a price data reload, so
- the entries a refresh rebuilds are the ones a fresh boot would produce.
- Nothing is recorded for replay: a refresh walks the live routers instead,
- so a deleted, repointed or never-added deployment, and a discarded router,
- drop out of the rebuild on their own.
-
- A strategy-router alias is never the deployment actually called or
- billed, so custom pricing configured on it must not become a cost-map
- price: an explicit zero would let ``_is_cost_explicitly_configured``
- treat the alias as a genuinely free model and waive budget checks for
- requests that route to (and bill as) a real deployment.
- """
- if classify_strategy_router_model(model) is not None:
- model_info = {k: v for k, v in model_info.items() if k not in CustomPricingLiteLLMParams.model_fields}
-
- if model_id is not None:
- litellm.register_model(
- model_cost={model_id: model_info},
- persist_across_reloads=False,
- warning_display_name=model,
- custom_llm_provider=pricing_provider or custom_llm_provider,
- add_to_provider_model_sets=False,
- )
-
- ## OLD MODEL REGISTRATION ## Kept to prevent breaking changes
- backend_keys: Final = Router._backend_cost_map_keys(model=model, custom_llm_provider=custom_llm_provider)
-
- # For the shared backend key, keep only cost-map schema fields
- # (minus custom pricing) so that one deployment's pricing overrides
- # or custom metadata (id, access_via_team_ids, arbitrary keys)
- # don't pollute another deployment sharing the same backend model
- # name. Each deployment's full model_info is already stored under
- # its unique model_id above.
- shared_model_info: Final = shared_backend_model_info(model_info)
- litellm.register_model(
- model_cost={_key: keep_registered_mode(_key, shared_model_info) for _key in backend_keys},
- persist_across_reloads=False,
- )
-
- def replay_model_cost_registrations(self) -> None:
- """Re-assert this router's deployments onto a freshly fetched catalog.
-
- Reads ``model_list`` at call time, so only deployments the router still
- serves are restored, plus any config deployment the fresh catalog now resolves.
- """
- provider_unresolved: Final = self._provider_unresolved_deployments
- self._provider_unresolved_deployments = ()
- for create_deployment in provider_unresolved:
- create_deployment()
- for entry in tuple(self._core_model_list):
- try:
- deployment = entry if isinstance(entry, Deployment) else Deployment(**entry)
- except Exception: # noqa: BLE001 # a malformed entry must not abort the rest of the rebuild
- verbose_router_logger.exception(
- "Router: could not rebuild cost-map entry for a deployment during a price data reload"
- )
- continue
- Router._register_deployment_in_model_cost(
- model_id=deployment.model_info.id,
- model_info=Router._deployment_model_cost_payload(deployment),
- model=deployment.litellm_params.model,
- custom_llm_provider=deployment.litellm_params.custom_llm_provider,
- pricing_provider=deployment.model_info.pricing_provider,
- )
- self._invalidate_model_group_info_cache()
-
- _replay_model_cost_registrations = replay_model_cost_registrations
-
- def _core_delete_deployment(self, id: str) -> Deployment | None:
- """
- Parameters:
- - id: str - the id of the deployment to be deleted
-
- Returns:
- - The deleted deployment
- - OR None (if deleted deployment not found)
- """
- try:
- with self._deployment_index_lock:
- deployment_idx: Final = self.model_id_to_deployment_index_map.get(id)
- if deployment_idx is None:
- return None
- # Pop the item from the list first
- item: Final = self._core_model_list.pop(deployment_idx)
- self._invalidate_model_group_info_cache()
- self._invalidate_access_groups_cache()
- self._update_deployment_indices_after_removal(model_id=id, removal_idx=deployment_idx)
- _budget_limiter: Final = self._core_get_router_deployment_budget_limiter()
- if _budget_limiter is not None:
- _budget_limiter.unregister_deployment_budget(model_id=id)
- try:
- self._unregister_pre_routing_strategy_for_deployment(
- deployment=item if isinstance(item, Deployment) else Deployment(**item)
- )
- except Exception:
- verbose_router_logger.exception(
- "delete_deployment: could not release pre-routing strategies for model_id=%s; "
- "the deployment is out of the model_list and its indices are repaired",
- id,
- )
- except Exception:
- return None
- return item
-
- def _core_get_router_deployment_budget_limiter(
- self,
- ) -> RouterBudgetLimiting | None:
- """
- Return the router's deployment-budget callback.
-
- Uses exact-type matching so proxy subclasses (e.g. virtual-key model budgets)
- registered on litellm.callbacks are not mistaken for router deployment budgets.
- """
- if self.router_budget_logger is not None:
- return self.router_budget_logger
-
- if self.optional_callbacks:
- for _cb in self.optional_callbacks:
- if type(_cb) is RouterBudgetLimiting:
- self.router_budget_logger = _cb
- return _cb
- return None
-
- def _deployment_has_budget_limits(self, deployment: Deployment) -> bool:
- return (
- deployment.litellm_params.get("max_budget") is not None
- and deployment.litellm_params.get("budget_duration") is not None
- and deployment.model_info.id is not None
- )
-
- def _sync_deployment_budget_config(self, deployment: Deployment) -> None:
- model_id: Final = deployment.model_info.id
- if model_id is None:
- return
-
- _budget_limiter = self._core_get_router_deployment_budget_limiter()
-
- if not self._deployment_has_budget_limits(deployment=deployment):
- if _budget_limiter is not None:
- _budget_limiter.unregister_deployment_budget(model_id=model_id)
- return
-
- if _budget_limiter is None:
- self.add_optional_pre_call_checks(optional_pre_call_checks=["router_budget_limiting"])
- _budget_limiter = self._core_get_router_deployment_budget_limiter()
-
- if _budget_limiter is not None:
- _budget_limiter.register_deployment_budget(deployment=deployment.to_json(exclude_none=True))
-
- def _core_deployments_named(self, model_name: str) -> tuple[DeploymentTypedDict, ...] | None:
- """The deployments indexed under `model_name`, read under the index lock, or None when the name is not indexed."""
- with self._deployment_index_lock:
- indices: Final = self._core_model_name_to_deployment_indices.get(model_name)
- return None if indices is None else tuple(self._core_model_list[idx] for idx in indices)
-
- def _served_openai_models(self, model_name: str) -> tuple[str | None, ...]:
- return served_openai_models(self._core_deployments_named(model_name) or ())
-
- def team_public_model_names_of(self, team_id: str) -> frozenset[str]:
- """The public names `team_id` serves its own deployments under."""
- with self._deployment_index_lock:
- return frozenset(name for owner, name in self._core_team_model_to_deployment_indices if owner == team_id)
-
- def _team_deployments_named(self, team_id: str, public_model_name: str) -> tuple[DeploymentTypedDict, ...] | None:
- """The deployments `team_id` serves under `public_model_name`, or None when that pair is not indexed."""
- with self._deployment_index_lock:
- indices: Final = self._core_team_model_to_deployment_indices.get((team_id, public_model_name))
- return None if indices is None else tuple(self._core_model_list[idx] for idx in indices)
-
- def _core_get_deployment(self, model_id: str) -> Deployment | None:
- """
- Returns -> Deployment or None
-
- Raise Exception -> if model found in invalid format
- """
- # Use O(1) lookup via model_id_to_deployment_index_map only
- with self._deployment_index_lock:
- idx: Final = self.model_id_to_deployment_index_map.get(model_id)
- model: Final = None if idx is None else self._core_model_list[idx]
- if model is not None:
- if isinstance(model, dict):
- return Deployment(**model)
- elif isinstance(model, Deployment):
- return model
- else:
- raise Exception(f"Model invalid format - {type(model)}")
-
- return None
-
- def _core_get_deployment_credentials(self, model_id: str) -> dict | None:
- """
- Returns -> dict of credentials for a given model id.
-
- Returns None if the deployment is paused via `LiteLLM_ProxyModelTable.blocked`,
- so file/batch/passthrough callers that resolve credentials directly cannot keep
- using a paused deployment.
- """
- deployment: Final = self._core_get_deployment(model_id=model_id)
- if deployment is None or self._core_is_deployment_blocked(deployment):
- return None
- return CredentialLiteLLMParams.model_validate(
- deployment.litellm_params.model_dump(exclude_none=True)
- ).model_dump(exclude_none=True)
-
- def _core_get_deployment_by_model_group_name(self, model_group_name: str) -> Deployment | None:
- """
- Returns -> Deployment or None
-
- Raise Exception -> if model found in invalid format
-
- Optimized with O(1) index lookup instead of O(n) linear scan.
- """
- # O(1) lookup in model_name index
- with self._deployment_index_lock:
- indices: Final = self._core_model_name_to_deployment_indices.get(model_group_name)
- # Return first deployment for this model_name
- model: Final = self._core_model_list[indices[0]] if indices else None
- if model is not None:
- if isinstance(model, dict):
- return Deployment(**model)
- elif isinstance(model, Deployment):
- return model
- else:
- raise Exception(f"Model Name invalid - {type(model)}")
- return None
-
- @staticmethod
- def _core_deployment_usable_by_team(model: Mapping[str, object] | Deployment, team_id: str | None) -> bool:
- """
- A team-scoped deployment (``model_info.team_id`` set) is only usable by
- callers from that same team; deployments without a team owner are shared.
- """
- model_info: Final = model.get("model_info") if isinstance(model, dict) else model.model_info
- owner_team_id: Final = cast(Mapping[str, object], model_info).get("team_id") if model_info is not None else None
- return team_may_use_deployment(owner_team_id, team_id)
-
- def _get_model_group_deployment_usable_by_team(
- self, model_group_name: str, team_id: str | None
- ) -> Deployment | None:
- """
- Like ``get_deployment_by_model_group_name``, but skips deployments owned
- by other teams so a shared model name never resolves another team's
- credentials.
- """
- with self._deployment_index_lock:
- indices: Final = self._core_model_name_to_deployment_indices.get(model_group_name) or ()
- usable: Final = (
- self._core_model_list[idx]
- for idx in indices
- if self._core_deployment_usable_by_team(self._core_model_list[idx], team_id)
- )
- first_usable: Final = next(usable, None)
- if first_usable is None:
- return None
- return Deployment(**first_usable) if isinstance(first_usable, dict) else first_usable
-
- async def _core_arefresh_model_info(self, *, client: AsyncHTTPHandler | None = None) -> None:
- """Refresh token limits advertised by configured OpenAI-compatible deployments."""
- deployments: Final = iter(tuple(self._core_model_list))
-
- async def refresh_worker() -> None:
- for raw_deployment in deployments:
- try:
- await self._arefresh_deployment_model_info(raw_deployment, client=client)
- except Exception: # noqa: BLE001 # one invalid deployment must not prevent refreshing the others
- verbose_router_logger.debug("Could not refresh deployment model info")
-
- await asyncio.gather(*(refresh_worker() for _ in range(MODEL_INFO_REFRESH_CONCURRENCY)))
- self._invalidate_model_group_info_cache()
-
- async def _arefresh_deployment_model_info(
- self, raw_deployment: Mapping[str, object], *, client: AsyncHTTPHandler | None
- ) -> None:
- deployment: Final = Deployment.model_validate(raw_deployment)
- params: Final = LiteLLM_Params.model_validate(
- MappingProxyType(
- {
- **deployment.litellm_params.model_dump(exclude_none=True),
- **(
- self._core_get_deployment_credentials_with_provider(deployment.model_info.id or "")
- or MappingProxyType({})
- ),
- }
- )
- )
- model, provider, dynamic_api_key, api_base = litellm.get_llm_provider(model=params.model, litellm_params=params)
- if provider not in MODEL_INFO_DISCOVERY_PROVIDERS:
- return
- if api_base is None or "*" in model or params.get("use_clientside_credentials"):
- return
- api_key: Final = await aresolve_api_key_secret_reference(
- params.api_key or dynamic_api_key,
- model=model,
- llm_provider=provider,
- upstream=MappingProxyType({"api_base": params.api_base}),
- )
- headers: Final = TypeAdapter(Mapping[str, str]).validate_python(
- params.get("extra_headers") or params.get("headers") or MappingProxyType({})
- )
- auth_headers: Final = (
- MappingProxyType({"authorization": f"Bearer {api_key}"}) if api_key else MappingProxyType({})
- )
- limits: Final = await get_openai_compatible_model_info(
- model=model,
- api_base=api_base,
- headers=MappingProxyType(
- {
- **auth_headers,
- **MappingProxyType({key.lower(): value for key, value in headers.items()}),
- }
- ),
- client=client or get_async_httpx_client(llm_provider=LlmProviders.OPENAI),
- cache=self._core_cache.in_memory_cache,
- )
- model_id: Final = deployment.model_info.id
- if not limits or model_id is None or self._core_get_model_info(model_id) is not raw_deployment:
- return
- self._discovered_model_info_cache.max_size_in_memory = max(len(self._core_model_list), 1)
- self._discovered_model_info_cache.delete_cache(model_id)
- self._discovered_model_info_cache.set_cache(
- model_id, DiscoveredDeploymentModelInfo(deployment=raw_deployment, limits=limits)
- )
- self._invalidate_model_group_info_cache()
-
- def _core_get_discovered_model_info(self, model_id: str | None) -> Mapping[str, int]:
- cached: Final[object] = self._discovered_model_info_cache.get_cache(model_id)
- if (
- model_id is not None
- and isinstance(cached, DiscoveredDeploymentModelInfo)
- and cached.deployment is self._core_get_model_info(model_id)
- ):
- configured: Final = TypeAdapter(Mapping[str, object]).validate_python(cached.deployment["model_info"])
- return MappingProxyType({key: value for key, value in cached.limits.items() if configured.get(key) is None})
- return MappingProxyType({})
-
- def _core_get_model_listing_info(self, model_name: str) -> DeploymentModelListingInfo | None:
- """
- Return what the concrete deployments behind model_name contribute to its
- /v1/models entry: the cost-map keys for their underlying models, plus the widest
- configured or discovered token limits. Resolved via O(1) index
- lookup.
-
- Returns None for wildcard-expanded or unknown names, where the listed name is the
- real model name and no deployment-specific information exists, and treats a
- malformed configured limit as absent rather than failing the listing.
-
- The whole group is read rather than just its first deployment, so a group that
- mixes models does not advertise a window that depends on config order; the widest
- one is reported, which is what get_model_group_info already shows the Admin UI.
- Keys are deduplicated, so the ordinary group of interchangeable deployments of one
- model still costs the caller a single cost-map lookup. Unlike get_model_group_info,
- this never triggers pattern matching or deep copies, so it is safe to call per
- listed model on the /v1/models hot path.
- """
- deployments: Final = self._core_deployments_named(model_name)
- if not deployments:
- return None
-
- model_infos: Final = tuple(
- MappingProxyType(
- {
- **self._core_get_discovered_model_info(
- (deployment.get("model_info") or MappingProxyType({})).get("id")
- ),
- **MappingProxyType(
- {
- k: v
- for k, v in (deployment.get("model_info") or MappingProxyType({})).items()
- if v is not None
- }
- ),
- }
- )
- for deployment in deployments
- )
- raw_deployments: Final[tuple[object, ...]] = deployments
- pricing_keys: Final = (deployment_pricing_models(deployment) for deployment in raw_deployments)
- cost_map_keys: Final = tuple(dict.fromkeys(keys[0] for keys in pricing_keys if keys))
- return DeploymentModelListingInfo(
- cost_map_keys=cost_map_keys,
- max_input_tokens=self._widest_configured_limit(model_infos, "max_input_tokens"),
- max_output_tokens=self._widest_configured_limit(model_infos, "max_output_tokens"),
- )
-
- @staticmethod
- def _widest_configured_limit(model_infos: Sequence[Mapping[str, object]], field: str) -> int | None:
- """The largest usable value of ``field`` across a group's configured model_info blocks."""
- limits: Final = tuple(
- limit
- for limit in (coerce_token_limit(model_info.get(field)) for model_info in model_infos)
- if limit is not None
- )
- return max(limits) if limits else None
-
- def _core_get_configured_token_limits(self, model_name: str) -> "tuple[int | None, int | None]":
- """
- Return (max_input_tokens, max_output_tokens) configured or discovered for a concrete
- deployment of model_name, via O(1) index lookup.
-
- Returns (None, None) for wildcard-expanded or unknown names, and treats a
- malformed configured value as absent rather than failing the caller.
-
- Deliberately reads one deployment rather than aggregating the group the way
- get_model_listing_info does: its caller truncates an embedding input to this
- value, so the widest window in a mixed group would be the wrong answer there.
- """
- deployment: Final = self._core_get_deployment_by_model_group_name(model_group_name=model_name)
- if deployment is None:
- return (None, None)
-
- model_info: Final = MappingProxyType(
- {
- **self._core_get_discovered_model_info(deployment.model_info.id),
- **deployment.model_info.model_dump(exclude_none=True),
- }
- )
- return (
- coerce_token_limit(model_info.get("max_input_tokens")),
- coerce_token_limit(model_info.get("max_output_tokens")),
- )
-
- def _core_get_configured_mode(self, model_name: str) -> "str | None":
- """Return the mode explicitly configured for a concrete deployment."""
- deployment: Final = self._core_get_deployment_by_model_group_name(model_group_name=model_name)
- if deployment is None:
- return None
-
- mode: Final = deployment.model_info.get("mode")
- if isinstance(mode, str) and mode.strip():
- return mode
- return None
-
- def _core_get_configured_display_name(self, model_name: str) -> "str | None":
- """
- Return the display_name explicitly configured in a concrete deployment's
- model_info for model_name, via O(1) index lookup.
-
- Returns None for wildcard-expanded or unknown names, and treats a
- non-string or empty configured value as absent rather than failing the
- listing. Like get_configured_token_limits, this never triggers pattern
- matching or deep copies, so it is safe to call per listed model on the
- /v1/models hot path.
- """
- deployment: Final = self._core_get_deployment_by_model_group_name(model_group_name=model_name)
- if deployment is None:
- return None
-
- display_name: Final = deployment.model_info.get("display_name")
- if isinstance(display_name, str) and display_name.strip():
- return display_name
- return None
-
- def _core_routable_model_group(self, model_name: str) -> str:
- """
- The model group a request to model_name routes to: its target when
- model_name is a `model_group_alias`, else model_name itself.
- """
- target: Final = self._core_get_model_from_alias(model_name)
- return target if target is not None else model_name
-
- def _routable_deployments(self, model_name: str, team_id: str | None) -> tuple[DeploymentTypedDict, ...]:
- """
- The deployments a request from team_id to model_name can route to, in
- model_list order and via the same O(1) index lookup as
- get_configured_display_name, selected the way routing selects them: a
- `model_group_alias` reads its target's deployments, another team's
- deployment of the name is left out, a caller with no team reads the
- deployments no team owns (every deployment of the name when a team owns
- each one, which is what an admin's request routes to), and one an admin
- paused via `LiteLLM_ProxyModelTable.blocked` is left out.
-
- Returns an empty tuple for wildcard-expanded or unknown names.
- """
- named: Final = self._core_get_all_deployments(
- model_name=self._core_routable_model_group(model_name), team_id=team_id
- )
- usable: Final = tuple(
- deployment for deployment in named if self._core_deployment_usable_by_team(deployment, team_id)
- )
- return tuple(
- deployment
- for deployment in (usable or named)
- if _configured_model_info(deployment).get("blocked") is not True
- )
-
- def _core_get_configured_service_tiers(self, model_name: str, team_id: str | None = None) -> tuple[object, ...]:
- """
- Return the service_tiers value each deployment a request from team_id
- to model_name can route to (see _routable_deployments) configures in
- its model_info, unvalidated and in model_list order, None for a
- deployment that sets none; the caller validates the shape it expects
- and decides how the deployments combine.
-
- Returns an empty tuple for wildcard-expanded or unknown names.
- """
- return tuple(
- _configured_model_info(deployment).get("service_tiers")
- for deployment in self._routable_deployments(model_name, team_id)
- )
-
- def _core_get_search_tool_support(self, model_name: str, team_id: str | None = None) -> tuple[bool | None, ...]:
- """
- Whether each deployment a request from team_id to model_name can route
- to (see _routable_deployments) answers Codex's client `tool_search`:
- its model_info.supports_search_tool when that is a bool, else True when
- its route builds an Anthropic Messages request, else None (unknown).
- """
- return tuple(_search_tool_support(deployment) for deployment in self._routable_deployments(model_name, team_id))
-
- def _core_get_routable_upstream_model(self, model_name: str, team_id: str | None = None) -> str | None:
- """
- Return the `litellm_params.model` of the first deployment a request
- from team_id to model_name can route to (see _routable_deployments).
-
- Returns None for wildcard-expanded or unknown names, and when the team
- can route to no deployment of the name.
- """
- deployment: Final = next(iter(self._routable_deployments(model_name, team_id)), None)
- return deployment["litellm_params"].get("model") if deployment is not None else None
-
- def _core_get_credential_deployment(self, model_id: str, team_id: str | None = None) -> Deployment | None:
- """
- The deployment a passthrough endpoint (files, batches, etc.) resolves for a
- model id or model name: by deployment id first, then by model_name, then by
- the team's exact public model name, then by wildcard pattern (team wildcards
- before global ones, so a global "openai/*" never shadows the team's own
- entry). Name and wildcard lookups never resolve another team's deployment.
-
- Returns None when nothing matches or the match is paused via
- `LiteLLM_ProxyModelTable.blocked`, so callers cannot bypass an admin pause
- by resolving the deployment directly.
- """
- deployment: Final = (
- self._core_get_deployment(model_id=model_id)
- or self._get_model_group_deployment_usable_by_team(model_group_name=model_id, team_id=team_id)
- or self._get_team_public_name_deployment(model_id=model_id, team_id=team_id)
- or self._get_wildcard_deployment_usable_by_team(model_id=model_id, team_id=team_id)
- )
- if deployment is None or self._core_is_deployment_blocked(deployment):
- return None
- return deployment
-
- def _get_team_public_name_deployment(self, model_id: str, team_id: str | None) -> Deployment | None:
- if team_id is None:
- return None
- with self._deployment_index_lock:
- team_indices: Final = self._core_team_model_to_deployment_indices.get((team_id, model_id))
- team_model: Final = self._core_model_list[team_indices[0]] if team_indices else None
- if team_model is None:
- return None
- return Deployment(**team_model) if isinstance(team_model, dict) else team_model
-
- def _get_wildcard_deployment_usable_by_team(self, model_id: str, team_id: str | None) -> Deployment | None:
- team_pattern_router: Final = self._core_team_pattern_routers.get(team_id) if team_id is not None else None
- team_wildcard_models: Final = team_pattern_router.route(model_id) if team_pattern_router else None
- global_wildcard_models: Final = tuple(
- wildcard_model
- for wildcard_model in (self._core_pattern_router.route(model_id) or ())
- if self._core_deployment_usable_by_team(wildcard_model, team_id)
- )
- potential_wildcard_models: Final = team_wildcard_models or global_wildcard_models
- if not potential_wildcard_models:
- return None
- wildcard_deployment: Final = potential_wildcard_models[0]
- if isinstance(wildcard_deployment, dict):
- return Deployment(**wildcard_deployment)
- if isinstance(wildcard_deployment, Deployment):
- return wildcard_deployment
- return None
-
- def _core_get_deployment_credentials_with_provider(
- self, model_id: str, team_id: str | None = None
- ) -> dict[str, Any] | None:
- """
- Get API credentials and provider info from a model name in model_list.
- Useful for passthrough endpoints (files, batches, etc.) that need credentials.
-
- Resolves the deployment with `get_credential_deployment` (by deployment id,
- then model_name, team public model name, and wildcard pattern).
-
- Args:
- model_id: Model ID or model name from model_list (e.g., "gpt-4o-litellm")
- team_id: Optional team id of the caller. When set, team-scoped
- deployments (indexed by team public model name, including team
- wildcard models like "openai/*") are also considered. Name and
- wildcard lookups never resolve a deployment owned by a
- different team, so shared model names can't leak another
- team's credentials.
-
- Returns:
- Dictionary containing api_key, api_base, custom_llm_provider, etc.
- Returns None if model not found, or if the resolved deployment is
- paused via `LiteLLM_ProxyModelTable.blocked` (so passthrough callers
- cannot bypass an admin pause by resolving credentials directly).
-
- Example:
- credentials = router.get_deployment_credentials_with_provider("gpt-4o-litellm")
- # Returns: {"api_key": "sk-...", "custom_llm_provider": "openai", "model": "gpt-4o", ...}
- """
- deployment: Final = self._core_get_credential_deployment(model_id=model_id, team_id=team_id)
- if deployment is None:
- return None
-
- # Get basic credentials
- credentials: Final = CredentialLiteLLMParams.model_validate(
- deployment.litellm_params.model_dump(exclude_none=True)
- ).model_dump(exclude_none=True)
-
- # Resolve litellm_credential_name to actual credentials
- if deployment.litellm_params.litellm_credential_name is not None:
- credential_values: Final = CredentialAccessor.get_credential_values(
- deployment.litellm_params.litellm_credential_name
- )
- if not credential_values:
- verbose_router_logger.warning(
- "Credential '%s' not found in credential_list", deployment.litellm_params.litellm_credential_name
- )
- credentials.update(credential_values)
- # Remove the credential name since we've resolved it
- credentials.pop("litellm_credential_name", None)
-
- credentials["model"] = deployment.litellm_params.model
-
- # Add custom_llm_provider
- if deployment.litellm_params.custom_llm_provider:
- credentials["custom_llm_provider"] = deployment.litellm_params.custom_llm_provider
- elif "/" in deployment.litellm_params.model:
- # Extract provider from "provider/model" format
- credentials["custom_llm_provider"] = deployment.litellm_params.model.split("/")[0]
- else:
- credentials["custom_llm_provider"] = "openai" # default
-
- return credentials
-
- @overload
- def _core_get_router_model_info(
- self,
- deployment: Union[dict, "Deployment"],
- received_model_name: str,
- id: None = None,
- ) -> ModelMapInfo:
- pass
-
- @overload
- def _core_get_router_model_info(self, deployment: None, received_model_name: str, id: str) -> ModelMapInfo:
- pass
-
- def _core_get_router_model_info(
- self,
- deployment: Union[dict, "Deployment"] | None,
- received_model_name: str,
- id: str | None = None,
- ) -> ModelMapInfo:
- """
- For a given model id, return the model info (max tokens, input cost, output cost, etc.).
-
- Augment litellm info with additional params set in `model_info`.
-
- For azure models, ignore the `model:`. Only set max tokens, cost values if base_model is set.
- For every other provider, a base_model naming the same model as `model:` can only widen its context window.
-
- Returns
- - ModelInfo - If found -> typed dict with max tokens, input cost, etc.
-
- Raises:
- - ValueError -> If model is not mapped yet
- """
- if id is not None:
- _deployment: Final = self._core_get_deployment(model_id=id)
- if _deployment is not None:
- deployment = _deployment
-
- if deployment is None:
- raise ValueError("Deployment not found")
-
- ## GET BASE MODEL
- base_model = (deployment.get("model_info") or {}).get("base_model", None)
- if base_model is None:
- base_model = (deployment.get("litellm_params") or {}).get("base_model", None)
-
- model = base_model
-
- ## GET PROVIDER - reuse LiteLLM_Params if already constructed
- litellm_params_data: Final = deployment.get("litellm_params")
- litellm_params: LiteLLM_Params
- if isinstance(litellm_params_data, LiteLLM_Params):
- litellm_params = litellm_params_data
- elif isinstance(litellm_params_data, dict) and "model" in litellm_params_data:
- litellm_params = LiteLLM_Params.model_validate(litellm_params_data)
- else:
- raise ValueError(
- f"Deployment missing valid litellm_params. "
- f"Got: {type(litellm_params_data).__name__}, "
- f"deployment_id: {(deployment.get('model_info') or {}).get('id', 'unknown')}"
- )
- _model, custom_llm_provider, _, _ = litellm.get_llm_provider(
- model=litellm_params.model,
- litellm_params=litellm_params,
- )
-
- ## SET MODEL TO 'model=' - if base_model is None + not azure
- if custom_llm_provider == "azure" and base_model is None:
- # Router init auto-registers every deployment name into
- # litellm.model_cost as a zeroed stub, so membership alone can't
- # tell a resolvable name apart; require usable limits/costs.
- _azure_fallback_key = _model if _model.startswith("azure/") else f"azure/{_model}"
- _fallback_entry = litellm.model_cost.get(_azure_fallback_key)
- _fallback_resolves = _fallback_entry is not None and (
- (_fallback_entry.get("max_input_tokens") or 0) > 0
- or (_fallback_entry.get("input_cost_per_token") or 0) > 0
- )
- if _fallback_resolves:
- verbose_router_logger.debug(
- "Azure deployment '%s' has no base_model set; using '%s' from the model cost map for max tokens, cost tracking, etc.",
- _model,
- _azure_fallback_key,
- )
- else:
- verbose_router_logger.error(
- "Could not identify azure model '%s'. Set azure 'base_model' for accurate max tokens, cost tracking, etc.- https://docs.litellm.ai/docs/proxy/cost_tracking#spend-tracking-for-azure-openai-models",
- _model,
- )
- elif custom_llm_provider != "azure":
- model = _model
-
- if "*" in model: # only call pattern_router for wildcard models
- potential_models: Final = self._core_pattern_router.route(received_model_name)
- if potential_models is not None:
- for potential_model in potential_models:
- try:
- if (potential_model.get("model_info") or {}).get("id") == (
- deployment.get("model_info") or {}
- ).get("id"):
- model = (potential_model.get("litellm_params") or {}).get("model")
- break
- except Exception:
- pass
-
- ## GET LITELLM MODEL INFO - raises exception, if model is not mapped
- if model is None:
- # Handle case where base_model is None (e.g., Azure models without base_model set)
- # Use the original model from litellm_params
- model = _model
-
- if not model.startswith(f"{custom_llm_provider}/"):
- model_info_name = f"{custom_llm_provider}/{model}"
- else:
- model_info_name = model
-
- model_info: Final = litellm.get_model_info(model=model_info_name)
- if model_info is None:
- return model_info
-
- ## CHECK USER SET MODEL INFO
- raw_user_model_info: Final = deployment.get("model_info")
- user_model_info: Final = _MODEL_INFO_ADAPTER.validate_python(
- raw_user_model_info.model_dump(exclude_none=True)
- if isinstance(raw_user_model_info, BaseModel)
- else raw_user_model_info or {}
- )
-
- # get_model_info() hands back an lru_cache'd dict, so merge into a copy; unset
- # values are skipped or Deployment's None pricing defaults would erase the map's
- merged_model_info: Final[ModelMapInfo] = {
- **copy.deepcopy(model_info),
- **_base_model_widened_window(
- model_info,
- _declared_base_model(user_model_info) or _declared_base_model(litellm_params.model_extra or {}),
- custom_llm_provider,
- ),
- **self._core_get_discovered_model_info((deployment.get("model_info") or {}).get("id")),
- **MappingProxyType({key: value for key, value in user_model_info.items() if value is not None}),
- }
-
- return merged_model_info
-
- def _core_get_model_info(self, id: str) -> dict | None:
- """
- For a given model id, return the model info
-
- Returns
- - dict: the model in list with 'model_name', 'litellm_params', Optional['model_info']
- - None: could not find deployment in list
-
- Optimized with O(1) index lookup instead of O(n) linear scan.
- """
- # O(1) lookup via model_id_to_deployment_index_map
- with self._deployment_index_lock:
- idx: Final = self.model_id_to_deployment_index_map.get(id)
- return None if idx is None else self._core_model_list[idx]
-
- def _core_get_model_group(self, id: str) -> list | None:
- """
- Return list of all models in the same model group as that model id
- """
-
- model_info: Final = self._core_get_model_info(id=id)
- if model_info is None:
- return None
-
- model_name: Final = model_info["model_name"]
- return self._core_get_model_list(model_name=model_name)
-
- def _core_get_deployment_model_info(self, model_id: str, model_name: str) -> ModelInfo | None:
- """
- For a given model id, return the model info
-
- 1. Check if model_id is in model info
- 2. If not, check if litellm model name is in model info
- 3. If not, return None
- """
- from litellm.utils import cost_map_omits_token_price, update_dictionary
-
- model_info: ModelInfo | None = None
- custom_model_info: dict | None = None
- litellm_model_name_model_info: ModelInfo | None = None
- base_model_key: str | None = None
-
- try:
- custom_model_info = (
- {
- **copy.deepcopy(litellm.model_cost.get(model_id) or MappingProxyType({})),
- **self._core_get_discovered_model_info(model_id),
- }
- if model_id in litellm.model_cost
- else None
- )
- except Exception:
- pass
-
- try:
- litellm_model_name_model_info = litellm.get_model_info(model=model_name)
- except Exception:
- pass
-
- ## check for base model
- try:
- if custom_model_info is not None:
- base_model: Final = custom_model_info.get("base_model", None)
- if base_model is not None:
- ## update litellm model info with base model info
- base_model_info: Final = copy.deepcopy(litellm.get_model_info(model=base_model))
- if base_model_info is not None:
- base_model_key = base_model_info.get("key")
- # Base model provides defaults, custom model info overrides
- custom_model_info = update_dictionary(
- cast(dict, base_model_info),
- custom_model_info,
- )
- except Exception:
- pass
-
- # Three mutually exclusive scenarios for the model's metadata:
- if custom_model_info is not None and litellm_model_name_model_info is not None:
- # (1) It has both custom model_info set and exists in the built-in map
- # merge with custom overriding built-in
- model_info = cast(
- ModelInfo,
- update_dictionary(
- copy.deepcopy(cast(dict, litellm_model_name_model_info)),
- custom_model_info,
- ),
- )
- elif litellm_model_name_model_info is not None:
- # (2) Built-in only — no custom pricing to merge
- model_info = copy.deepcopy(litellm_model_name_model_info)
- elif custom_model_info is not None:
- # (3) Custom only — model not in built-in cost map yet
- # custom_model_info already includes base_model defaults at this point, if applicable
- model_info = cast(ModelInfo, custom_model_info)
-
- if model_info is None:
- return None
- builtin_key: Final = (
- litellm_model_name_model_info.get("key") if litellm_model_name_model_info is not None else None
- )
- if cost_map_omits_token_price(model_id, builtin_key, base_model_key):
- return cast( # cast-ok: TypedDict spread with overridden keys loses its type
- ModelInfo, {**model_info, "input_cost_per_token": None, "output_cost_per_token": None}
- )
- return model_info
-
- @staticmethod
- def _model_group_llm_provider(litellm_params: LiteLLM_Params) -> tuple[str, str]:
- try:
- return resolve_model_and_provider_for_metadata(
- model=litellm_params.model,
- custom_llm_provider=litellm_params.custom_llm_provider,
- )
- except litellm.exceptions.BadRequestError as e:
- verbose_router_logger.error("litellm.router.py::get_model_group_info() - %s", e)
- return "", ""
-
- def _set_model_group_info(
- self, model_group: str, user_facing_model_group_name: str, team_id: str | None = None
- ) -> ModelGroupInfo | None:
- """
- For a given model group name, return the combined model info
-
- Returns:
- - ModelGroupInfo if able to construct a model group
- - None if error constructing model group info
- """
- model_group_info: ModelGroupInfo | None = None
-
- total_tpm: int | None = None
- total_rpm: int | None = None
- total_itpm: int | None = None
- total_otpm: int | None = None
- configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None
- reasoning_efforts_initialized = False
- reasoning_efforts_unknown = False
- serves_team_deployments: Final = team_id is not None and bool(
- self._team_deployments_named(team_id, model_group)
- )
- model_list: Final = self._core_get_model_list_of_routed_group(model_group, team_id)
- model_group_description: Final[str | None] = next(
- (d for d in (_configured_model_info(m).get("description") for m in model_list) if isinstance(d, str)),
- None,
- )
- for model in model_list:
- is_match = False
- if (
- serves_team_deployments
- or "model_name" in model
- and model["model_name"] == model_group
- or "model_name" in model
- and self._core_pattern_router.route(model_group) is not None
- ): # exact match
- is_match = True
-
- if not is_match:
- continue
- # model in model group found #
- litellm_params = LiteLLM_Params(**model["litellm_params"])
- # get configurable clientside auth params
- configurable_clientside_auth_params = litellm_params.configurable_clientside_auth_params
-
- # Cache nested dict access to avoid repeated temporary dict allocations
- model_litellm_params = model.get("litellm_params", {})
- model_info_dict = model.get("model_info", {})
-
- # get model tpm
- _deployment_tpm: int | None = None
- if _deployment_tpm is None:
- _deployment_tpm = model.get("tpm", None)
- if _deployment_tpm is None:
- _deployment_tpm = model_litellm_params.get("tpm", None)
- if _deployment_tpm is None:
- _deployment_tpm = model_info_dict.get("tpm", None)
-
- # get model rpm
- _deployment_rpm: int | None = None
- if _deployment_rpm is None:
- _deployment_rpm = model.get("rpm", None)
- if _deployment_rpm is None:
- _deployment_rpm = model_litellm_params.get("rpm", None)
- if _deployment_rpm is None:
- _deployment_rpm = model_info_dict.get("rpm", None)
-
- _deployment_itpm: int | None = model.get("itpm")
- if _deployment_itpm is None:
- _deployment_itpm = model_litellm_params.get("itpm", None)
- if _deployment_itpm is None:
- _deployment_itpm = model_info_dict.get("itpm", None)
-
- _deployment_otpm: int | None = model.get("otpm")
- if _deployment_otpm is None:
- _deployment_otpm = model_litellm_params.get("otpm", None)
- if _deployment_otpm is None:
- _deployment_otpm = model_info_dict.get("otpm", None)
-
- # get model info
- try:
- model_id = model_info_dict.get("id", None)
- if model_id is not None:
- model_info = self._core_get_deployment_model_info(
- model_id=model_id, model_name=litellm_params.model
- )
- else:
- model_info = None
- except Exception:
- model_info = None
-
- deployment_is_mapped = deployment_is_catalog_mapped(model_info, model_info_dict)
-
- # get llm provider
- litellm_model, llm_provider = self._model_group_llm_provider(litellm_params)
-
- if model_info is None:
- supported_openai_params = litellm.get_supported_openai_params(
- model=litellm_model, custom_llm_provider=llm_provider
- )
- if supported_openai_params is None:
- supported_openai_params = []
-
- # Get mode from database model_info if available, otherwise default to "chat"
- db_model_info = model.get("model_info", {})
- mode = db_model_info.get("mode", "chat")
- input_cost_per_token = _cost_value_as_float(db_model_info.get("input_cost_per_token"))
- output_cost_per_token = _cost_value_as_float(db_model_info.get("output_cost_per_token"))
-
- model_info = ModelMapInfo(
- key=model_group,
- max_tokens=None,
- max_input_tokens=None,
- max_output_tokens=None,
- input_cost_per_token=input_cost_per_token,
- output_cost_per_token=output_cost_per_token,
- litellm_provider=llm_provider,
- mode=mode,
- supported_openai_params=supported_openai_params,
- supports_system_messages=None,
- )
-
- if model_group_info is None:
- model_group_info = ModelGroupInfo(
- **{
- "model_group": user_facing_model_group_name,
- "providers": [llm_provider],
- "description": model_group_description,
- **model_info,
- "supports_fast_mode": True,
- "supported_reasoning_efforts": None,
- "supported_endpoints": None,
- }
- )
- else:
- # if max_input_tokens > curr
- # if max_output_tokens > curr
- # if input_cost_per_token > curr
- # if output_cost_per_token > curr
- # supports_parallel_function_calling == True
- # supports_vision == True
- # supports_function_calling == True
- if llm_provider not in model_group_info.providers:
- model_group_info.providers.append(llm_provider)
- if (
- model_info.get("max_input_tokens", None) is not None
- and model_info["max_input_tokens"] is not None
- and (
- model_group_info.max_input_tokens is None
- or model_info["max_input_tokens"] > model_group_info.max_input_tokens
- )
- ):
- model_group_info.max_input_tokens = model_info["max_input_tokens"]
- if (
- model_info.get("max_output_tokens", None) is not None
- and model_info["max_output_tokens"] is not None
- and (
- model_group_info.max_output_tokens is None
- or model_info["max_output_tokens"] > model_group_info.max_output_tokens
- )
- ):
- model_group_info.max_output_tokens = model_info["max_output_tokens"]
- _input_cost_per_token = _cost_value_as_float(model_info.get("input_cost_per_token"))
- if _input_cost_per_token is not None and (
- model_group_info.input_cost_per_token is None
- or _input_cost_per_token > (model_group_info.input_cost_per_token or 0.0)
- ):
- model_group_info.input_cost_per_token = _input_cost_per_token
- _output_cost_per_token = _cost_value_as_float(model_info.get("output_cost_per_token"))
- if _output_cost_per_token is not None and (
- model_group_info.output_cost_per_token is None
- or _output_cost_per_token > (model_group_info.output_cost_per_token or 0.0)
- ):
- model_group_info.output_cost_per_token = _output_cost_per_token
- if (
- model_info.get("supports_parallel_function_calling", None) is not None
- and model_info["supports_parallel_function_calling"] is True
- ):
- model_group_info.supports_parallel_function_calling = True
- if model_info.get("supports_vision", None) is not None and model_info["supports_vision"] is True:
- model_group_info.supports_vision = True
- if (
- model_info.get("supports_function_calling", None) is not None
- and model_info["supports_function_calling"] is True
- ):
- model_group_info.supports_function_calling = True
- if (
- model_info.get("supports_web_search", None) is not None
- and model_info["supports_web_search"] is True
- ):
- model_group_info.supports_web_search = True
- if (
- model_info.get("supports_url_context", None) is not None
- and model_info["supports_url_context"] is True
- ):
- model_group_info.supports_url_context = True
-
- if model_info.get("supports_reasoning", None) is not None and model_info["supports_reasoning"] is True:
- model_group_info.supports_reasoning = True
- if (
- model_info.get("supported_openai_params", None) is not None
- and model_info["supported_openai_params"] is not None
- ):
- model_group_info.supported_openai_params = model_info["supported_openai_params"]
- if model_info.get("tpm", None) is not None and _deployment_tpm is None:
- _deployment_tpm = model_info.get("tpm")
- if model_info.get("rpm", None) is not None and _deployment_rpm is None:
- _deployment_rpm = model_info.get("rpm")
-
- model_group_info.supported_endpoints = (
- tuple(
- dict.fromkeys(
- (
- *(model_group_info.supported_endpoints or ()),
- *(
- deployment_supported_endpoints(
- model_info.get("supported_endpoints"), model, llm_provider
- )
- if team_may_use_deployment(_configured_model_info(model).get("team_id"), team_id)
- else ()
- ),
- )
- )
- )
- or None
- )
- model_group_info.supports_fast_mode = model_group_info.supports_fast_mode and (
- AnthropicModelInfo.supports_fast_mode(litellm_model, llm_provider)
- )
- deployment_reasoning_efforts = resolve_supported_reasoning_efforts(
- model_info, deployment_is_mapped=deployment_is_mapped
- )
- if deployment_reasoning_efforts is None:
- reasoning_efforts_unknown = True
- model_group_info.supported_reasoning_efforts = None
- elif not reasoning_efforts_initialized:
- reasoning_efforts_initialized = True
- if not reasoning_efforts_unknown:
- model_group_info.supported_reasoning_efforts = deployment_reasoning_efforts
- elif not reasoning_efforts_unknown:
- model_group_info.supported_reasoning_efforts = intersect_supported_reasoning_efforts(
- model_group_info.supported_reasoning_efforts,
- deployment_reasoning_efforts,
- )
-
- if _deployment_tpm is not None:
- if total_tpm is None:
- total_tpm = 0
- total_tpm += _deployment_tpm
-
- if _deployment_rpm is not None:
- if total_rpm is None:
- total_rpm = 0
- total_rpm += _deployment_rpm
-
- if _deployment_itpm is not None:
- if total_itpm is None:
- total_itpm = 0
- total_itpm += _deployment_itpm
-
- if _deployment_otpm is not None:
- if total_otpm is None:
- total_otpm = 0
- total_otpm += _deployment_otpm
- if model_group_info is not None:
- ## UPDATE WITH TOTAL TPM/RPM FOR MODEL GROUP
- if total_tpm is not None:
- model_group_info.tpm = total_tpm
-
- if total_rpm is not None:
- model_group_info.rpm = total_rpm
-
- if total_itpm is not None:
- model_group_info.itpm = total_itpm
-
- if total_otpm is not None:
- model_group_info.otpm = total_otpm
-
- ## UPDATE WITH CONFIGURABLE CLIENTSIDE AUTH PARAMS FOR MODEL GROUP
- if configurable_clientside_auth_params is not None:
- model_group_info.configurable_clientside_auth_params = configurable_clientside_auth_params
-
- return model_group_info
-
- def _core_get_model_group_info(
- self, model_group: str, *, include_hidden: bool = False, team_id: str | None = None
- ) -> ModelGroupInfo | None:
- """
- For a given model group name, return the combined model info
-
- Returns:
- - ModelGroupInfo if able to construct a model group
- - None if error constructing model group info or hidden model group (unless include_hidden)
- """
- ## Check if model group alias
- if model_group in self._core_model_group_alias:
- item: Final = self._core_model_group_alias[model_group]
- if isinstance(item, str):
- _router_model_group = item
- elif isinstance(item, dict):
- if item["hidden"] is True and not include_hidden:
- return None
- else:
- _router_model_group = item["model"]
- else:
- return None
-
- return self._set_model_group_info(
- model_group=_router_model_group,
- user_facing_model_group_name=model_group,
- team_id=team_id,
- )
-
- ## Check if actual model
- return self._set_model_group_info(
- model_group=model_group, user_facing_model_group_name=model_group, team_id=team_id
- )
-
- async def _core_get_model_group_usage(self, model_group: str) -> tuple[int | None, int | None]:
- """
- Returns current tpm/rpm usage for model group
-
- Parameters:
- - model_group: str - the received model name from the user (can be a wildcard route).
-
- Returns:
- - usage: Tuple[tpm, rpm]
- """
- dt: Final = get_utc_datetime()
- current_minute: Final = dt.strftime("%H-%M") # use the same timezone regardless of system clock
- tpm_keys: Final[list[str]] = []
- rpm_keys: Final[list[str]] = []
-
- model_list: Final = self._core_get_model_list(model_name=model_group)
- if model_list is None: # no matching deployments
- return None, None
-
- for model in model_list:
- id: str | None = model.get("model_info", {}).get("id")
- litellm_model: str | None = model["litellm_params"].get(
- "model"
- ) # USE THE MODEL SENT TO litellm.completion() - consistent with how global_router cache is written.
- if id is None or litellm_model is None:
- continue
- tpm_keys.append(
- RouterCacheEnum.TPM.value.format(
- id=id,
- model=litellm_model,
- current_minute=current_minute,
- )
- )
- rpm_keys.append(
- RouterCacheEnum.RPM.value.format(
- id=id,
- model=litellm_model,
- current_minute=current_minute,
- )
- )
- combined_tpm_rpm_keys: Final = tpm_keys + rpm_keys
-
- combined_tpm_rpm_values: Final = await self._core_cache.async_batch_get_cache(keys=combined_tpm_rpm_keys)
- if combined_tpm_rpm_values is None:
- return None, None
-
- tpm_usage_list: Final[list | None] = combined_tpm_rpm_values[: len(tpm_keys)]
- rpm_usage_list: Final[list | None] = combined_tpm_rpm_values[len(tpm_keys) :]
-
- ## TPM
- tpm_usage: int | None = None
- if tpm_usage_list is not None:
- for t in tpm_usage_list:
- if isinstance(t, int):
- if tpm_usage is None:
- tpm_usage = 0
- tpm_usage += t
- ## RPM
- rpm_usage: int | None = None
- if rpm_usage_list is not None:
- for t in rpm_usage_list:
- if isinstance(t, int):
- if rpm_usage is None:
- rpm_usage = 0
- rpm_usage += t
- return tpm_usage, rpm_usage
-
- async def get_model_group_io_token_usage(self, model_group: str) -> tuple[int | None, int | None]:
- """
- Returns current ITPM/OTPM usage for a model group (sum across deployments).
- """
- dt: Final = get_utc_datetime()
- current_minute: Final = dt.strftime("%H-%M")
- itpm_keys: Final[list[str]] = []
- otpm_keys: Final[list[str]] = []
-
- model_list: Final = self._core_get_model_list(model_name=model_group)
- if model_list is None:
- return None, None
-
- for model in model_list:
- model_id: str | None = model.get("model_info", {}).get("id")
- litellm_model: str | None = model["litellm_params"].get("model")
- if model_id is None or litellm_model is None:
- continue
- itpm_keys.append(
- RouterCacheEnum.ITPM.value.format(
- id=model_id,
- model=litellm_model,
- current_minute=current_minute,
- )
- )
- otpm_keys.append(
- RouterCacheEnum.OTPM.value.format(
- id=model_id,
- model=litellm_model,
- current_minute=current_minute,
- )
- )
-
- combined_values: Final = await self._core_cache.async_batch_get_cache(keys=itpm_keys + otpm_keys)
- if combined_values is None:
- return None, None
-
- itpm_values: Final = combined_values[: len(itpm_keys)]
- otpm_values: Final = combined_values[len(itpm_keys) :]
-
- total_itpm: int | None = None
- for value in itpm_values:
- if isinstance(value, int):
- total_itpm = (total_itpm or 0) + value
-
- total_otpm: int | None = None
- for value in otpm_values:
- if isinstance(value, int):
- total_otpm = (total_otpm or 0) + value
-
- return total_itpm, total_otpm
-
- @lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)
- def _cached_get_model_group_info(self, model_group: str) -> ModelGroupInfo | None:
- """
- Cached version of get_model_group_info, uses @lru_cache wrapper
-
- This is a speed optimization, since set_response_headers makes a call to get_model_group_info on every request
- """
- return self._core_get_model_group_info(model_group)
-
- def _core_cached_model_group_info(self, model_group: str) -> ModelGroupInfo | None:
- return self._cached_get_model_group_info(model_group)
-
- async def _core_get_remaining_model_group_usage(self, model_group: str) -> dict[str, int]:
- model_group_info: Final = self._cached_get_model_group_info(model_group)
-
- returned_dict: Final[dict[str, int]] = {}
-
- # ITPM/OTPM groups emit input/output token headers, but they may also set
- # tpm/rpm, so build both sets rather than returning early - clients and
- # prometheus gauges that read the standard headers still get data.
- if model_group_info is not None and (model_group_info.itpm is not None or model_group_info.otpm is not None):
- current_itpm, current_otpm = await self.get_model_group_io_token_usage(model_group)
- returned_dict.update(
- build_io_token_rate_limit_headers(
- itpm_limit=model_group_info.itpm,
- otpm_limit=model_group_info.otpm,
- current_itpm=current_itpm,
- current_otpm=current_otpm,
- )
- )
-
- tpm_limit: Final = model_group_info.tpm if model_group_info is not None else None
- rpm_limit: Final = model_group_info.rpm if model_group_info is not None else None
-
- if tpm_limit is None and rpm_limit is None:
- return returned_dict
-
- current_tpm, current_rpm = await self._core_get_model_group_usage(model_group)
-
- if tpm_limit is not None:
- returned_dict["x-ratelimit-remaining-tokens"] = tpm_limit - (current_tpm or 0)
- returned_dict["x-ratelimit-limit-tokens"] = tpm_limit
- if rpm_limit is not None:
- returned_dict["x-ratelimit-remaining-requests"] = rpm_limit - (current_rpm or 0)
- returned_dict["x-ratelimit-limit-requests"] = rpm_limit
-
- return returned_dict
-
- async def set_response_headers(
- self,
- response: object,
- model_group: str | None = None,
- request_kwargs: dict[str, object] | None = None,
- ) -> Any:
- """
- Add the most accurate rate limit headers for a given model response.
-
- ## TODO: add model group rate limit headers
- # - if healthy_deployments > 1, return model group rate limit headers
- # - else return the model's rate limit headers
- """
- response = prepare_response_for_header_attachment(response)
- if response is None:
- return response
-
- additional_headers: Final = ensure_response_additional_headers(response)
- apply_response_model_id(
- response, find_deployment_metadata(request_kwargs) if request_kwargs is not None else None
- )
- additional_headers["x-litellm-model-group"] = model_group
- apply_quality_router_decision_headers(additional_headers, request_kwargs)
- additional_headers.update(complexity_router_decision_headers(request_kwargs))
-
- if model_group is not None:
- remaining_usage: Final = await self._core_get_remaining_model_group_usage(model_group)
- apply_remaining_usage_headers(additional_headers, remaining_usage)
- return response
-
- def _build_model_name_index(self, model_list: list) -> None:
- """
- Build model_name -> deployment indices mapping for O(1) lookups.
-
- This index allows us to find all deployments for a given model_name in O(1) time
- instead of O(n) linear scan through the entire model_list.
- """
- self._core_model_name_to_deployment_indices.clear()
- self._core_team_model_to_deployment_indices.clear()
- self._core_team_public_model_names = frozenset()
-
- for idx, model in enumerate(model_list):
- model_name = model.get("model_name")
- if model_name:
- if model_name not in self._core_model_name_to_deployment_indices:
- self._core_model_name_to_deployment_indices[model_name] = []
- self._core_model_name_to_deployment_indices[model_name].append(idx)
-
- self._update_team_model_index(model, idx)
-
- def _build_model_id_to_deployment_index_map(self, model_list: list):
- """
- Build model index from model list to enable O(1) lookups immediately.
- This is called during initialization to avoid the race condition where
- requests arrive before model_id_to_deployment_index_map is populated.
- """
- # First populate the model_list
- self._core_model_list = []
- self._invalidate_model_group_info_cache()
- self._invalidate_access_groups_cache()
- for _, model in enumerate(model_list):
- # Extract model_info from the model dict
- model_info = model.get("model_info", {})
- model_id = model_info.get("id")
-
- # If no ID exists, generate one using the same logic as set_model_list
- if model_id is None:
- model_name = model.get("model_name", "")
- litellm_params = model.get("litellm_params", {})
- model_id = self._core_generate_model_id(model_name, litellm_params)
- # Update the model_info in the original list
- if "model_info" not in model:
- model["model_info"] = {}
- model["model_info"]["id"] = model_id
-
- self._add_model_to_list_and_index_map(model=model, model_id=model_id)
-
- def _core_get_model_ids(self, model_name: str | None = None, exclude_team_models: bool = False) -> list[str]:
- """
- if 'model_name' is none, returns all.
-
- Returns list of model id's.
-
- Optimized with O(1) or O(k) index lookup when model_name provided,
- instead of O(n) linear scan.
- """
- ids: Final = []
-
- if model_name is not None:
- # O(1) lookup in model_name index, then O(k) iteration where k = deployments for this model_name
- for model in self._core_deployments_named(model_name) or ():
- if "model_info" in model and "id" in model["model_info"]:
- if exclude_team_models and model["model_info"].get("team_id"):
- continue
- ids.append(model["model_info"]["id"])
- else:
- # When model_name is None, return all model IDs
- # Use the index map keys for O(n) where n = total deployments
- with self._deployment_index_lock:
- id_indices: Final = tuple(self.model_id_to_deployment_index_map.items())
- models: Final = tuple(self._core_model_list)
- for model_id, idx in id_indices:
- model = models[idx]
- if "model_info" in model and "id" in model["model_info"]:
- if exclude_team_models and model["model_info"].get("team_id"):
- continue
- ids.append(model_id)
-
- return ids
-
- def _core_get_candidate_model_ids_for_route(self, model: str, team_id: str | None = None) -> frozenset[str]:
- """
- Deployment ids that could serve ``model`` for ``team_id``, following the same
- precedence ``_common_checks_available_deployment`` uses to build a candidate pool:
- ``model_group_alias``, then a routing group, then the first matching early-resolve
- path for a name that is not a ``model_name`` (team route, wildcard pattern via
- ``get_deployments_by_pattern``, team pattern router, default deployment), then the
- ``model_name`` and team indexes. Delegating to the router's own resolvers keeps this
- aligned with how a route actually resolves rather than re-deriving it, and unlike
- ``_common_checks_available_deployment`` it is read-only: it does not apply request
- fallbacks and (with ``include_team_models`` left off) does not raise. Lets a pre-call
- check tell a genuine cross-group route from same-group unavailability without leaking
- deployment ids into request kwargs bound for the provider.
- """
- resolved: Final = self._core_get_model_from_alias(model=model) or model
- routing_group_members: Final = self._get_routing_group_deployments(model=resolved, team_id=team_id)
- if routing_group_members is not None:
- return self._deployment_ids(routing_group_members)
- early: Final = self._try_early_resolve_deployments_for_model_not_in_names(
- model=resolved, request_team_id=team_id
- )
- if early is not None:
- early_deployments: Final = early[1]
- return self._deployment_ids(
- (early_deployments,) if isinstance(early_deployments, Mapping) else early_deployments
- )
- return self._deployment_ids(self._core_get_all_deployments(model_name=resolved, team_id=team_id))
-
- @staticmethod
- def _deployment_ids(deployments: Sequence[Mapping[str, object]]) -> frozenset[str]:
- return frozenset(
- str(model_info["id"])
- for deployment in deployments
- for model_info in (deployment.get("model_info"),)
- if isinstance(model_info, Mapping) and model_info.get("id") is not None
- )
-
- def _region_routable_deployments(
- self,
- deployments: Sequence[DeploymentTypedDict],
- request_kwargs: Mapping[str, object] | None,
- has_request_input: bool,
- ) -> tuple[Mapping[str, Any], ...]:
- """
- `deployments` without the ones `_pre_call_checks` keeps this request away from for being outside its allowed
- region, typed for the tag routing filter
- """
- allowed_model_region: Final = None if request_kwargs is None else request_kwargs.get("allowed_model_region")
- if allowed_model_region is None or not self._core_enable_pre_call_checks or not has_request_input:
- return tuple(deployments)
- if not isinstance(allowed_model_region, str):
- return ()
- return tuple(
- deployment
- for deployment in deployments
- if is_region_allowed(
- litellm_params=LiteLLM_Params.model_validate(deployment["litellm_params"]),
- allowed_model_region=allowed_model_region,
- )
- )
-
- async def _async_routable_cooled_deployment_ids(
- self,
- model: str,
- request_deployments: Sequence[DeploymentTypedDict],
- cooldown_deployments: Iterable[str],
- request_kwargs: Mapping[str, object],
- has_request_input: bool,
- ) -> frozenset[str]:
- """
- Ids of the cooling deployments in `request_deployments` that region and tag routing would send this request
- to, each tag routing pick counted along with the picks it falls back to once that one is unavailable, judged
- without recording a tag match on the request
- """
- cooled_ids: Final = frozenset(cooldown_deployments)
- metadata_variable_name: Final = get_metadata_variable_name_from_kwargs(request_kwargs)
- metadata: Final = request_kwargs.get(metadata_variable_name)
- if metadata_variable_name in request_kwargs and not isinstance(metadata, Mapping):
- return frozenset()
- tag_request: Final[Mapping[str, Any]] = (
- {**request_kwargs, metadata_variable_name: {**metadata}}
- if isinstance(metadata, Mapping)
- else request_kwargs
- )
- routable_ids = frozenset[str]() # rebind-ok: gains each pass's tag routing picks
- remaining = tuple( # rebind-ok: each pass drops what tag routing just picked
- deployment
- for deployment in self._region_routable_deployments(request_deployments, request_kwargs, has_request_input)
- if self._deployment_ids((deployment,)) <= cooled_ids
- )
- while remaining:
- try:
- picked = await get_deployments_for_tag(
- ctx=self._core_ctx,
- model=model,
- healthy_deployments=remaining,
- request_kwargs=tag_request,
- metadata_variable_name=metadata_variable_name,
- request_deployments=request_deployments,
- )
- except ValueError: # tag routing sends this request to none of the rest
- break
- picked_identities = frozenset(id(deployment) for deployment in picked)
- rest = tuple(deployment for deployment in remaining if id(deployment) not in picked_identities)
- if len(rest) == len(remaining):
- break
- routable_ids |= self._deployment_ids(
- tuple(deployment for deployment in remaining if id(deployment) in picked_identities)
- )
- remaining = rest
- return routable_ids
-
- def _core_has_model_id(self, candidate_id: str) -> bool:
- """
- O(1) membership check for a deployment ID without allocating large lists.
-
- Note: Call sites may pass a variable named `model` when it actually
- contains a deployment ID. This helper expects the deployment ID string.
-
- Uses the existing `model_id_to_deployment_index_map` which is kept
- in sync by `_build_model_id_to_deployment_index_map` and model-list
- mutation helpers.
- """
- return candidate_id in self.model_id_to_deployment_index_map
-
- def _core_resolve_model_name_from_model_id(
- self, model_id: str | None, custom_llm_provider: str | None = None
- ) -> str | None:
- """
- Resolve model_name from model_id.
-
- This method attempts to find the correct model_name to use with the router
- so that litellm_params can be automatically injected from the model config.
-
- Strategy:
- 1. First, check if model_id directly matches a model_name or deployment ID
- 2. If not, search through router's model_list to find a match by litellm_params.model
- 3. If not, keep model_id when the router routes that name itself (an alias, a routing group, or a wildcard
- whose deployment serves `custom_llm_provider`), else name it `custom_llm_provider/model_id` when only that
- form routes to such a wildcard
- 4. Return the model_name if found, None otherwise
-
- Args:
- model_id: The model_id extracted from decoded video_id
- (could be model_name or litellm_params.model value)
- custom_llm_provider: The provider extracted from the same id
-
- Returns:
- model_name if found, None otherwise. If None, the request will fall through
- to normal flow using environment variables.
- """
- if not model_id:
- return None
-
- # Strategy 1: Check if model_id directly matches a model_name or deployment ID
- if model_id in self._core_model_names:
- return model_id
- if self._core_has_model_id(model_id):
- deployment = self._core_get_deployment(model_id=model_id)
- if deployment is not None and deployment.model_name:
- return deployment.model_name
- return model_id
-
- # Strategy 2: Search through router's model_list to find by litellm_params.model
- all_models: Final = self._core_get_model_list(model_name=None)
- if not all_models:
- return None
-
- for deployment in all_models:
- litellm_params = deployment.get("litellm_params", {})
- actual_model = litellm_params.get("model")
-
- # Match by exact match or by checking if actual_model ends with /model_id or :model_id
- # e.g., model_id="veo-2.0-generate-001" matches actual_model="vertex_ai/veo-2.0-generate-001"
- matches = (
- actual_model == model_id
- or (actual_model and actual_model.endswith(f"/{model_id}"))
- or (actual_model and actual_model.endswith(f":{model_id}"))
- )
-
- if matches:
- model_name = deployment.get("model_name")
- if model_name:
- return model_name
-
- if self._core_is_recognized_model(model_id):
- return model_id
- if custom_llm_provider is None:
- return None
- if self._core_pattern_router.routes_to_provider(model_id, custom_llm_provider):
- return model_id
- provider_prefixed: Final = f"{custom_llm_provider}/{model_id}"
- if not model_id.startswith(f"{custom_llm_provider}/") and self._core_pattern_router.routes_to_provider(
- provider_prefixed, custom_llm_provider
- ):
- return provider_prefixed
- return None
-
- def _core_resolve_served_model_name(self, model_name: str, served_model: str) -> str:
- """
- `served_model` when `model_name` routes to it (the alias target, the routing group member, or the name
- itself), so a request can return to the group that served an earlier one, else `model_name`.
- """
- target: Final = self._core_get_model_from_alias(model_name) or model_name
- routing_group: Final = self.get_routing_group(target)
- members: Final = routing_group.models if routing_group is not None else ()
- return served_model if served_model == target or served_model in members else model_name
-
- def _core_map_team_model(
- self, team_model_name: str | None, team_id: str, custom_llm_provider: str | None = None
- ) -> str | None:
- """
- Resolve explicit team models or select a deployment for model-less calls.
-
- Model-less calls with no provider use the first team deployment. With a provider, they
- use the first team deployment of that provider, pinned by ID when its public name also
- covers another provider.
- """
- models: Final = self._core_get_model_list(model_name=team_model_name, team_id=team_id)
- if not models:
- return None
- if team_model_name is None and custom_llm_provider is not None:
-
- def team_deployment_public_name(deployment: DeploymentTypedDict) -> str | None:
- model_info: Final = _configured_model_info(deployment)
- if model_info.get("team_id") != team_id:
- return None
- team_public_model_name: Final = model_info.get("team_public_model_name")
- return (
- team_public_model_name
- if isinstance(team_public_model_name, str) and team_public_model_name
- else deployment["model_name"]
- )
-
- matching_deployment: Final = next(
- (
- model
- for model in models
- if team_deployment_public_name(model) is not None
- and provider_for_generic_call(model["litellm_params"]) == custom_llm_provider
- ),
- None,
- )
- if matching_deployment is None:
- return None
- public_name: Final = team_deployment_public_name(matching_deployment)
- if public_name is None:
- return None
- same_public_name_deployments: Final = tuple(
- candidate for candidate in models if team_deployment_public_name(candidate) == public_name
- )
- if all(
- provider_for_generic_call(candidate["litellm_params"]) == custom_llm_provider
- for candidate in same_public_name_deployments
- ):
- return public_name
- deployment_id: Final = _configured_model_info(matching_deployment).get("id")
- return deployment_id if isinstance(deployment_id, str) else None
- for model in models:
- if model.get("model_info", {}).get("team_id") == team_id:
- if team_model_name is None:
- # No model was specified (e.g. vector store endpoints).
- # Return the deployment's public model name so the router
- # can route to it and inject the BYOK API key.
- return model.get("model_info", {}).get("team_public_model_name") or model.get("model_name")
- return team_model_name
-
- # No team-scoped deployment found; wildcard/pattern routes are
- # handled downstream by the pattern_router in _common_checks_available_deployment.
- return None
-
- def _core_should_include_deployment(
- self, model_name: str, model: DeploymentTypedDict | dict, team_id: str | None = None
- ) -> bool:
- """
- Get the team-specific model name if team_id matches the deployment.
- """
- if (
- team_id is not None
- and (model.get("model_info") or {}).get("team_id") == team_id
- and model_name == (model.get("model_info") or {}).get("team_public_model_name")
- ):
- return True
- elif model_name is not None and model["model_name"] == model_name:
- # Fallback: check by internal model_name for non-team deployments
- # or deployments that haven't been migrated to team_public_model_name yet
- model_team_id: Final = (model.get("model_info") or {}).get("team_id")
- if (
- team_id is None # requester has no team constraint
- or model_team_id is None # global deployment - accessible to all teams
- or model_team_id == team_id # deployment belongs to requester's team
- ):
- return True
- # No match: deployment is for a different team or doesn't match the requested model
- return False
-
- def _core_get_all_deployments(
- self,
- model_name: str,
- model_alias: str | None = None,
- team_id: str | None = None,
- ) -> list[DeploymentTypedDict]:
- """
- Return all deployments of a model name
-
- Used for accurate 'get_model_list'.
-
- if team_id specified, only return team-specific models
-
- Optimized with O(1) index lookup instead of O(n) linear scan.
-
- Note: when team_id is provided, O(1) lookup in
- `team_model_to_deployment_indices` only applies when `model_name` is the
- team public model name. If a caller passes an internal deployment model
- name (for example, `model_name__`), this method falls back
- to the standard model-name index / scan path.
- """
- returned_models: Final[list[DeploymentTypedDict]] = []
-
- # O(1) lookup in team_model index when team_id is provided
- if team_id is not None:
- team_models: Final = self._team_deployments_named(team_id, model_name)
- if team_models is not None:
- # O(k) where k = team deployments for this model_name (typically 1-10)
- for model in team_models:
- if not self._core_should_include_deployment(model_name=model_name, model=model, team_id=team_id):
- continue
- if model_alias is not None:
- alias_model = model.copy()
- alias_model["model_name"] = model_alias
- returned_models.append(alias_model)
- else:
- returned_models.append(model)
- if returned_models:
- return returned_models
-
- # O(1) lookup in model_name index
- named_models: Final = self._core_deployments_named(model_name)
- if named_models is not None:
- # O(k) where k = deployments for this model_name (typically 1-10)
- for model in named_models:
- if self._core_should_include_deployment(model_name=model_name, model=model, team_id=team_id):
- if model_alias is not None:
- # Optimized: Use shallow copy since we only modify top-level model_name
- # This is much faster than deepcopy for nested dict structures
- alias_model = model.copy()
- alias_model["model_name"] = model_alias
- returned_models.append(alias_model)
- else:
- returned_models.append(model)
- elif team_id is not None:
- # Fallback: if team_id is provided and model_name not in index,
- # check if model_name matches any team_public_model_name
- # O(n) scan but only when team_id lookup fails
- for _idx, model in enumerate(self._core_model_list):
- if self._core_should_include_deployment(model_name=model_name, model=model, team_id=team_id):
- if model_alias is not None:
- # Optimized: Use shallow copy since we only modify top-level model_name
- alias_model = model.copy()
- alias_model["model_name"] = model_alias
- returned_models.append(alias_model)
- else:
- returned_models.append(model)
-
- return returned_models
-
- def _core_get_model_names(self, team_id: str | None = None) -> list[str]:
- """
- Returns all possible model names for the router, including models defined via model_group_alias.
-
- If a team_id is provided, only deployments configured with that team_id (i.e. team‐specific models)
- will yield their team public name.
- """
- deployments: Final = self._core_get_model_list() or []
- model_names: Final = []
-
- for deployment in deployments:
- model_info = deployment.get("model_info")
- if self._is_team_specific_model(model_info):
- team_model_name = self._get_team_specific_model(deployment=deployment, team_id=team_id)
- if team_model_name:
- model_names.append(team_model_name)
- else:
- model_names.append(deployment.get("model_name", ""))
-
- return model_names
-
- def _core_get_fully_blocked_model_names(self) -> set[str]:
- """
- Returns the set of model_names where every backing deployment has `blocked=True`.
-
- Used by `/v1/models` to hide paused models from client listings while still
- surfacing them on admin endpoints (e.g. `/model/info`). A model with at least
- one non-blocked deployment is still serviceable and remains visible.
- """
- deployments: Final = self._core_get_model_list() or []
- blocked_by_name: Final[dict[str, bool]] = {}
- for deployment in deployments:
- name = deployment.get("model_name") or ""
- if not name:
- continue
- is_blocked = (deployment.get("model_info") or {}).get("blocked") is True
- if name in blocked_by_name:
- blocked_by_name[name] = blocked_by_name[name] and is_blocked
- else:
- blocked_by_name[name] = is_blocked
- return {name for name, fully_blocked in blocked_by_name.items() if fully_blocked}
-
- @staticmethod
- def _core_are_all_deployments_blocked(
- deployments: Sequence[DeploymentTypedDict],
- ) -> bool:
- return len(deployments) > 0 and all(
- (deployment.get("model_info") or {}).get("blocked") is True for deployment in deployments
- )
-
- def _is_model_fully_blocked(self, model: str) -> bool:
- deployments: Final = self._core_get_model_list(model_name=model) or []
- return self._core_are_all_deployments_blocked(deployments=deployments)
-
- async def _core_async_get_fully_unhealthy_model_names(self) -> set[str]:
- """
- Returns the set of model names where every backing deployment is currently
- marked unhealthy by background health checks (and the health state is not stale).
-
- Used by `/v1/models?healthy_only=true` to hide models that cannot serve any
- request. A model with at least one healthy (or unknown-health) deployment
- remains visible. Returns an empty set when no health state is available, so
- callers fail open to the unfiltered listing.
-
- Notes:
- - Mirrors `_async_filter_health_check_unhealthy_deployments`: when
- `allowed_fails_policy` is set, cooldown is the sole routing exclusion
- mechanism, so nothing is hidden here either.
- - Team-specific public model names (`team_public_model_name`) are
- aggregated alongside `model_name`, so team aliases of fully-unhealthy
- deployments are hidden too (unlike `get_fully_blocked_model_names`,
- which matches `model_name` only).
- - Wildcard routes (e.g. `openai/*`) are matched by their literal
- deployment name only; models expanded from a wildcard route are not
- hidden (fail open).
- - Intentionally diverges from the routing-time safety net (which
- bypasses the health filter when every candidate is unhealthy and
- still attempts the request): hiding here is presentation-only —
- it answers "should this model be advertised?", not "should a
- request for it still be attempted?". A hidden model can still be
- called directly.
- """
- if self._core_allowed_fails_policy is not None:
- return set()
- unhealthy_ids: Final = await self._core_health_state_cache.async_get_unhealthy_deployment_ids()
- if not unhealthy_ids:
- return set()
- deployments: Final = self._core_get_model_list() or []
- unhealthy_by_name: Final[dict[str, bool]] = {}
- for deployment in deployments:
- model_info = deployment.get("model_info") or {}
- names = [deployment.get("model_name") or ""]
- team_public_model_name = model_info.get("team_public_model_name")
- if team_public_model_name:
- names.append(team_public_model_name)
- is_unhealthy = model_info.get("id") in unhealthy_ids
- for name in names:
- if not name:
- continue
- if name in unhealthy_by_name:
- unhealthy_by_name[name] = unhealthy_by_name[name] and is_unhealthy
- else:
- unhealthy_by_name[name] = is_unhealthy
- return {name for name, fully_unhealthy in unhealthy_by_name.items() if fully_unhealthy}
-
- def _get_team_specific_model(self, deployment: DeploymentTypedDict, team_id: str | None = None) -> str | None:
- """
- Get the team-specific model name if team_id matches the deployment.
-
- Args:
- deployment: DeploymentTypedDict - The model deployment
- team_id: Optional[str] - If passed, will return router models set with a `team_id` matching the passed `team_id`.
-
- Returns:
- str: The `team_public_model_name` if team_id matches
- None: If team_id doesn't match or no team info exists
- """
- model_info: Final[dict | None] = deployment.get("model_info") or {}
- if model_info is None:
- return None
- if team_id == model_info.get("team_id"):
- return model_info.get("team_public_model_name")
- return None
-
- def _is_team_specific_model(self, model_info: dict | None) -> bool:
- """
- Check if model info contains team-specific configuration.
-
- Args:
- model_info: Model information dictionary
-
- Returns:
- bool: True if model has team-specific configuration
- """
- return bool(model_info and model_info.get("team_id"))
-
- def _core_get_model_list_from_model_alias(self, model_name: str | None = None) -> list[DeploymentTypedDict]:
- """
- Helper function to get model list from model alias.
-
- Used by `.get_model_list` to get model list from model alias.
- """
- returned_models: Final[list[DeploymentTypedDict]] = []
-
- if model_name is not None:
- # Fast path: direct dict lookup avoids scanning all aliases for non-alias model names.
- if model_name not in self._core_model_group_alias:
- return returned_models
- alias_items = [(model_name, self._core_model_group_alias[model_name])]
- else:
- alias_items = list(self._core_model_group_alias.items())
-
- for model_alias, model_value in alias_items:
- if isinstance(model_value, str):
- _router_model_name: str = model_value
- elif isinstance(model_value, dict):
- _model_value = RouterModelGroupAliasItem(**model_value)
- if _model_value["hidden"] is True and model_name is None:
- continue
- _router_model_name = _model_value["model"]
- else:
- continue
-
- if (alias_group := self.get_routing_group(_router_model_name)) is not None:
- returned_models.extend(
- {**row, "model_name": model_alias} for row in self._materialize_routing_group_rows((alias_group,))
- )
- else:
- returned_models.extend(
- self._core_get_all_deployments(model_name=_router_model_name, model_alias=model_alias)
- )
-
- return returned_models
-
- def _core_get_model_list_from_routing_groups(self, model_name: str | None = None) -> Sequence[DeploymentTypedDict]:
- """
- Callable routing groups materialized as model-list rows, mirroring
- `get_model_list_from_model_alias`: each member deployment is emitted
- under the group's name (via `get_all_deployments`' `model_alias`
- rewrite), which is what surfaces groups in `get_model_names`,
- `/v1/models` discovery, `get_model_group_usage`, and the
- blocked/unhealthy hiding that all read `get_model_list`.
- """
- if model_name is not None:
- group: Final = self.get_routing_group(model_name)
- return self._materialize_routing_group_rows((group,)) if group is not None else ()
- cached: Final = self._routing_group_rows
- if cached is not None:
- return cached
- rows: Final = self._materialize_routing_group_rows(
- tuple(
- callable_group
- for name in self._core_routing_groups
- if (callable_group := self.get_routing_group(name)) is not None
- )
- )
- self._routing_group_rows = rows
- return rows
-
- def _materialize_routing_group_rows(self, groups: tuple[RoutingGroup, ...]) -> tuple[DeploymentTypedDict, ...]:
- return tuple(
- self._as_routing_group_row(apply_routing_group_priority(group, member, deployment))
- for group in groups
- for member in group.models
- for deployment in self._core_get_all_deployments(model_name=member, model_alias=group.group_name)
- )
-
- @staticmethod
- def _as_routing_group_row(deployment: DeploymentTypedDict) -> DeploymentTypedDict:
- """
- A member deployment re-emitted under its group's name must not carry
- the member's `access_groups`: access groups grant member names, never
- the group, so inheriting them here would let a key holding a member's
- access group list and call the whole group.
- """
- model_info: Final = {k: v for k, v in (deployment.get("model_info") or {}).items() if k != "access_groups"}
- return {**deployment, "model_info": model_info}
-
- TIER_PARAMS_NEVER_DROPPED: Final = frozenset(all_litellm_params) | frozenset(
- {
- "additional_drop_params",
- "drop_params",
- "messages",
- "model",
- "extra_headers",
- "max_tokens",
- "max_completion_tokens",
- }
+ Router.__init__.__signature__ = _CORE_SIGNATURE.replace( # pyright: ignore[reportFunctionMemberAccess] # inspect reads it
+ parameters=[parameter for parameter in _CORE_SIGNATURE.parameters.values() if parameter.name != "_host"]
)
-
- @staticmethod
- def _declared_param_allowlist(params: Mapping[str, object]) -> frozenset[str]:
- declared: Final = params.get("allowed_openai_params")
- if not isinstance(declared, (list, tuple, set, frozenset)):
- return frozenset()
- return frozenset(entry for entry in declared if isinstance(entry, str))
-
- @staticmethod
- def _deployment_accepts_param(deployment: DeploymentTypedDict, group: str, param: str) -> bool:
- deployment_params: Final = deployment.get("litellm_params")
- if not deployment_params:
- return True
- if param in Router._declared_param_allowlist(deployment_params):
- return True
- if declared_authenticating_provider(
- str(deployment_params.get("model") or ""), deployment_params.get("custom_llm_provider")
- ):
- return True
- return Router._deployment_supports_param(deployment, group, param)
-
- @staticmethod
- def _capability_model_and_provider(deployment_params: LiteLLMParamsTypedDict, group: str) -> tuple[str, str]:
- model_label: Final = str(deployment_params.get("model") or group)
- authenticating_provider: Final = declared_authenticating_provider(
- model_label, deployment_params.get("custom_llm_provider")
- )
- if authenticating_provider is not None:
- return model_label.removeprefix(f"{authenticating_provider}/"), authenticating_provider
- model, custom_llm_provider, _, _ = litellm.get_llm_provider(
- model=model_label,
- custom_llm_provider=deployment_params.get("custom_llm_provider"),
- litellm_params=LiteLLM_Params.model_validate(deployment_params),
- )
- return model, custom_llm_provider
-
- @staticmethod
- def _deployment_supports_param(deployment: DeploymentTypedDict, group: str, param: str) -> bool:
- deployment_params: Final = deployment.get("litellm_params")
- if not deployment_params:
- return True
- deployment_model_info: Final = deployment.get("model_info")
- base_model: Final = (
- deployment_model_info.get("base_model") if deployment_model_info else None
- ) or deployment_params.get("base_model")
- try:
- model, custom_llm_provider = Router._capability_model_and_provider(deployment_params, group)
- supported: Final = litellm.get_supported_openai_params(
- model=model,
- custom_llm_provider=custom_llm_provider,
- base_model=base_model if isinstance(base_model, str) else None,
- )
- except Exception as e: # noqa: BLE001 # best-effort filter: an unresolvable provider must not narrow the request
- verbose_router_logger.debug(
- "litellm.router.py::_deployment_supports_param: keeping %s for model=%s. Got - %s", param, group, e
- )
- return True
- return supported is None or param in supported
-
- def _tier_params_the_target_accepts(
- self, model: str, tier_params: Mapping[str, object], request_kwargs: Mapping[str, object]
- ) -> Mapping[str, object]:
- """Drop an OpenAI param that no deployment behind ``model`` declares.
-
- A tier's litellm_params are an operator override applied to every request the tier routes,
- so one the target cannot take turns that whole tier into a 400 raised before the request
- leaves the proxy. The candidates are exactly what get_optional_params can reject, asked of
- the module that raises, so credentials and endpoint controls are never at risk.
-
- TIER_PARAMS_NEVER_DROPPED is excluded on top of that, for two reasons. No provider lists a
- litellm control among its supported params, so "no deployment declares it" means litellm
- consumes it rather than that the target refuses it, and dropping one changes litellm's own
- behavior: dropping drop_params or additional_drop_params silently disables the sanitization
- the operator configured. Providers do list extra_headers, but it carries auth, tenancy and
- routing information, so sending fewer headers than configured is worse than today's error.
- Token ceilings stay for the same reason: a tier's max_tokens or max_completion_tokens is a
- cost bound, and dropping it would let a caller's own larger value through where today the
- mismatch fails loudly.
-
- The trade this filter makes is a param for a working request, which is right for one that
- only shapes how the model answers and wrong for anything else.
-
- A param survives if ANY deployment could take it, because routing has not chosen one yet,
- and it survives both an unresolvable provider and a group with no deployments, because a
- best-effort filter must never narrow what the request already did.
-
- A github_copilot or chatgpt deployment counts as accepting everything, decided before any
- lookup: resolving either provider runs its OAuth device flow, so a capability question
- asked from the routing path can freeze the event loop for minutes waiting on a human.
-
- allowed_openai_params is the documented escape hatch for an outdated or incomplete
- supported-params list: request-time validation extends the supported list with it before
- comparing. The filter asks the same question, so a param named by the allowlist on the tier
- overlay, the request, or a deployment's own litellm_params is never a drop candidate.
- """
- deployments: Final = self._core_get_model_list(model_name=model) or ()
- if not deployments:
- return tier_params
- allowlisted: Final = self._declared_param_allowlist(tier_params) | self._declared_param_allowlist(
- request_kwargs
- )
- candidates: Final = provider_rejectable_params(tier_params) - self.TIER_PARAMS_NEVER_DROPPED - allowlisted
- unsupported: Final = frozenset(
- param
- for param in candidates
- if not any(self._deployment_accepts_param(deployment, model, param) for deployment in deployments)
- )
- if not unsupported:
- return tier_params
- verbose_router_logger.warning(
- "litellm.router.py: dropping tier params %s for model=%s, no deployment behind it declares them",
- ", ".join(sorted(unsupported)),
- model,
- )
- return MappingProxyType({key: value for key, value in tier_params.items() if key not in unsupported})
-
- def _core_get_model_list(
- self, model_name: str | None = None, team_id: str | None = None
- ) -> list[DeploymentTypedDict] | None:
- """
- Includes router model_group_alias'es as well
-
- if team_id specified, returns matching team-specific models
- """
- # Note: model_list and model_group_alias are always initialized in __init__
- # so hasattr checks are unnecessary
- returned_models: list[DeploymentTypedDict] = []
-
- if model_name is not None:
- returned_models.extend(self._core_get_all_deployments(model_name=model_name, team_id=team_id))
-
- returned_models.extend(self._core_get_model_list_from_model_alias(model_name=model_name))
- returned_models.extend(self._core_get_model_list_from_routing_groups(model_name=model_name))
-
- if len(returned_models) == 0 and model_name is not None:
- returned_models.extend(self._get_wildcard_deployments(model_name=model_name, team_id=team_id))
-
- if model_name is None:
- returned_models += self._core_model_list
-
- return returned_models
-
- def _get_wildcard_deployments(self, model_name: str, team_id: str | None = None) -> list[DeploymentTypedDict]:
- """
- The deployments of the wildcard routes matching model_name (the proxy-wide
- ones, plus team_id's own when given), each emitted under model_name.
- """
- team_router: Final = self._core_team_pattern_routers.get(team_id) if team_id is not None else None
- matches: Final = [
- *self._core_pattern_router.get_deployments_by_pattern(model=model_name),
- *(team_router.get_deployments_by_pattern(model=model_name) if team_router is not None else ()),
- ]
- return [{**DeploymentTypedDict(**m), "model_name": model_name} for m in matches]
-
- def _core_wildcard_route_deployments(self, model: str, team_id: str | None) -> Sequence[Mapping[str, object]]:
- """The deployments routing serves ``model`` from through a wildcard: ``team_id``'s own pattern
- matching it, else the most specific proxy-wide pattern, never both."""
- team_router: Final = self._core_team_pattern_routers.get(team_id) if team_id is not None else None
- team_owned: Final[Sequence[Mapping[str, object]]] = (
- team_router.get_deployments_by_pattern(model=model) if team_router is not None else ()
- )
- return team_owned or self._core_pattern_router.get_deployments_by_pattern(model=model)
-
- def _core_wildcard_routed_models(self, model: str, team_id: str | None) -> frozenset[str]:
- """The models wildcard_route_deployments would route ``model`` to, without copying the deployments."""
- team_router: Final = self._core_team_pattern_routers.get(team_id) if team_id is not None else None
- team_owned: Final = (
- team_router.get_models_by_pattern(model=model) if team_router is not None else frozenset[str]()
- )
- return team_owned or self._core_pattern_router.get_models_by_pattern(model=model)
-
- def _core_team_ptu_ceiling(self, team_id: str, model_group: str) -> PTUTeamCeiling | None:
- """The per-minute ceiling ``team_id``'s PTU shares set on the group serving ``model_group``,
- else None, kept until the model list next changes."""
- if team_id not in self._ptu_share_teams():
- return None
- return self._cached_team_ptu_ceiling(team_id, model_group)
-
- def _collect_ptu_share_teams(self) -> frozenset[str]:
- return ptu_share_teams(self._core_get_model_list() or ())
-
- def _resolve_team_ptu_ceiling(self, team_id: str, model_group: str) -> PTUTeamCeiling | None:
- return team_ptu_ceiling(
- self._core_get_model_list() or (),
- self._core_model_list,
- team_id,
- model_group,
- lambda name: self._core_wildcard_route_deployments(name, team_id),
- )
-
- def _core_get_model_list_of_routed_group(
- self, model_group: str, team_id: str | None = None
- ) -> list[DeploymentTypedDict]:
- """
- The deployments a request the router has already resolved to model_group is
- served from: team_id's own deployments under that public name when the team has
- any, else the ones named model_group, the routing group of that name, or the
- wildcard route matching it when neither exists.
-
- Unlike get_model_list, model_group's own model_group_alias entry is not
- followed. The router resolves an alias exactly once, so a group reached as
- an alias target is served by its own deployments, never by a second hop:
- in the chain X -> T -> U a request to X is served from T's deployments.
- """
- if team_id is not None and self._team_deployments_named(team_id, model_group):
- return self._core_get_all_deployments(model_name=model_group, team_id=team_id)
- own: Final = cast( # cast-ok: the private alias returns what get_all_deployments returns
- list[DeploymentTypedDict], self._get_all_deployments(model_name=model_group)
- )
- named: Final = [*own, *self._core_get_model_list_from_routing_groups(model_name=model_group)]
- return named or self._get_wildcard_deployments(model_name=model_group)
-
- def _core_resolved_litellm_models(
- self, model_name: str, team_id: str | None = None, *, use_base_model: bool = False
- ) -> tuple[str, ...]:
- """The provider model strings `model_name` can actually be served by on this proxy.
-
- `get_model_list` composes every channel the request path itself uses (exact name,
- model_group_alias, routing groups, wildcards), so this answers "which models will
- answer a call to this name" rather than "what did the admin call it": the deployment
- name is admin-arbitrary, and two names over one provider model are one model.
-
- Empty when the name resolves to no deployment. That is not the same fact as "the
- call will fail" - a provider-qualified public name is served by the SDK with no
- deployment behind it - so the fallback for an empty result is the caller's policy,
- never this function's.
- """
- if use_base_model:
- return tuple(
- backend_model
- for deployment in self._core_get_model_list(model_name=model_name, team_id=team_id) or ()
- if (backend_model := self._cost_map_backend_model(deployment))
- )
- return tuple(
- litellm_model
- for deployment in self._core_get_model_list(model_name=model_name, team_id=team_id) or ()
- if isinstance(litellm_model := deployment.get("litellm_params", {}).get("model"), str) and litellm_model
- )
-
- def _invalidate_model_group_info_cache(self) -> None:
- """Invalidate the cached model group info.
-
- Call this whenever self._core_model_list is modified to ensure the cache is rebuilt.
- Also clears the auth-layer zero-cost cache, which depends on the same
- ``ModelGroupInfo`` data — without this, an in-place pricing update on
- an existing deployment (same model count) would keep a stale ``True``
- result and bypass budget enforcement.
- """
- self._cached_get_model_group_info.cache_clear()
- self._core_cached_deployment_model_info.cache_clear()
- self._ptu_share_teams.cache_clear()
- self._cached_team_ptu_ceiling.cache_clear()
- self._cached_deployment_tracks_usage.cache_clear()
- self.served_openai_models.cache_clear()
- self._zero_cost_cache.clear()
- self._routing_group_rows = None
-
- def _invalidate_access_groups_cache(self) -> None:
- """Invalidate the cached access groups.
-
- Call this whenever self._core_model_list is modified to ensure the cache is rebuilt. It also
- advances ``registry().version``, so every write path (``apply``, the old ``set_model_list``
- and ``add_deployment``) moves it.
- """
- self._access_groups_cache = None
- self._registry_version += 1
-
- def _core_get_model_access_groups(
- self,
- model_name: str | None = None,
- model_access_group: str | None = None,
- team_id: str | None = None,
- ) -> dict[str, list[str]]:
- """
- If model_name is provided, only return access groups for that model.
-
- Parameters:
- - model_name: Optional[str] - the received model name from the user (can be a wildcard route). If set, will only return access groups for that model.
- - model_access_group: Optional[str] - the received model access group from the user. If set, will only return models for that access group.
- - team_id: Optional[str] - the team id, to resolve team-specific models
- """
- # Check if this is the no-args hot path (cacheable)
- _use_cache: Final = model_name is None and model_access_group is None and team_id is None
-
- # Return cached result for the no-args hot path
- if _use_cache and self._access_groups_cache is not None:
- return self._access_groups_cache
-
- from collections import defaultdict
-
- access_groups: Final = defaultdict(list)
-
- model_list: Final = self._core_get_model_list(model_name=model_name, team_id=team_id)
- if model_list:
- for m in model_list:
- _model_info = m.get("model_info")
- if _model_info:
- for group in _model_info.get("access_groups", []) or []:
- if model_access_group is not None:
- if group == model_access_group:
- model_name = m["model_name"]
- access_groups[group].append(model_name)
- else:
- model_name = m["model_name"]
- access_groups[group].append(model_name)
-
- # Cache the result for the no-args hot path
- if _use_cache:
- self._access_groups_cache = dict(access_groups)
- return self._access_groups_cache
-
- return access_groups
-
- def _core_is_model_access_group_for_wildcard_route(self, model_access_group: str) -> bool:
- """
- Return True if model access group is a wildcard route
- """
- # GET ACCESS GROUPS
- access_groups: Final = self._core_get_model_access_groups(model_access_group=model_access_group)
-
- if len(access_groups) == 0:
- return False
-
- models: Final = access_groups.get(model_access_group, [])
-
- for model in models:
- # CHECK IF MODEL ACCESS GROUP IS A WILDCARD ROUTE
- if self._core_pattern_router.route(request=model) is not None:
- return True
-
- return False
-
- def _core_get_settings(self) -> dict[str, object]: # mutable-ok: the settings snapshot is built fresh per call
- """
- Get router settings method, returns a dictionary of the settings and their values.
- For example get the set values for routing_strategy_args, routing_strategy, allowed_fails, cooldown_time, num_retries, timeout, max_retries, retry_after
- """
- _all_vars: Final = vars(self)
- _settings_to_return: Final = {}
- vars_to_include: Final = [
- "routing_strategy_args",
- "routing_strategy",
- "allowed_fails",
- "cooldown_time",
- "num_retries",
- "timeout",
- "max_retries",
- "retry_after",
- "fallbacks",
- "context_window_fallbacks",
- "model_group_retry_policy",
- "retry_policy",
- "model_group_alias",
- "enable_weighted_failover",
- "enable_mid_stream_fallback_continuation",
- "enable_tag_filtering",
- "tag_routing_prefix",
- "fallback_on_unsupported_params",
- ]
-
- for var in vars_to_include:
- stored = var if var in _all_vars else "_core_" + var
- if stored in _all_vars:
- _settings_to_return[var] = _all_vars[stored]
- if (
- var == "routing_strategy_args"
- and self._core_routing_strategy == "latency-based-routing"
- and self.lowestlatency_logger is not None
- ):
- _settings_to_return[var] = self.lowestlatency_logger.routing_args.json()
-
- _settings_to_return["routing_groups"] = [
- group.model_dump(exclude=frozenset(("model_priorities",)) if group.model_priorities is None else None)
- for group in self._core_routing_groups.values()
- ]
- return _settings_to_return
-
- def _switch_routing_strategy(self, routing_strategy: str | None, kwargs: Mapping[str, object]) -> None:
- if routing_strategy == "lar1":
- from litellm.router_strategy.lar1_routing import apply_lar1_routing_strategy
-
- apply_lar1_routing_strategy(self._core_ctx, kwargs.get("routing_strategy_args"))
- return
- self.routing_strategy_init(
- routing_strategy=routing_strategy, routing_strategy_args=kwargs.get("routing_strategy_args", {})
- )
-
- def _core_update_settings(self, **kwargs) -> None:
- """
- Update the router settings.
- """
- _int_settings: Final = [
- "timeout",
- "num_retries",
- "retry_after",
- "allowed_fails",
- "cooldown_time",
- ]
-
- _existing_router_settings: Final = self._core_get_settings()
- model_group_alias_before: Final = self._core_model_group_alias
- rebuild_routing_groups = False
- routing_args_updated = False
- for var in kwargs:
- if var in RUNTIME_UPDATABLE_ROUTER_SETTINGS:
- if var in _int_settings:
- _casted_value = int(kwargs[var])
- setattr(self, var, _casted_value)
- elif var == "routing_groups":
- rebuild_routing_groups = True
- elif var == "optional_pre_call_checks":
- self.set_optional_pre_call_checks(kwargs[var])
- elif var == "enable_mid_stream_fallback_continuation":
- self._set_mid_stream_fallback_continuation(kwargs[var])
- elif var == "retry_policy":
- self._set_retry_policy(kwargs[var])
- else:
- value = kwargs[var]
- # only run routing strategy init if it has changed
- if var == "routing_strategy":
- value = self._normalize_strategy(value)
- if _existing_router_settings["routing_strategy"] != value:
- self._switch_routing_strategy(value, kwargs)
- rebuild_routing_groups = True
- elif var == "routing_strategy_args":
- routing_args_updated = value != self.routing_strategy_args
- setattr(self, var, value)
- else:
- verbose_router_logger.debug("Setting %s is not allowed", var)
-
- if routing_args_updated:
- self._apply_updated_routing_strategy_args()
-
- if self._core_model_group_alias != model_group_alias_before:
- self._invalidate_model_group_info_cache()
-
- if rebuild_routing_groups:
- routing_groups_input: Final = kwargs.get("routing_groups", self._routing_groups_input)
- self._init_routing_groups(routing_groups_input)
- self._routing_groups_input = routing_groups_input
- verbose_router_logger.debug("Updated Router settings: %s", self._core_get_settings())
-
- @staticmethod
- def _is_flag_enabled(value: object) -> bool:
- return value is True or (isinstance(value, str) and str_to_bool(value) is True)
-
- def _set_mid_stream_fallback_continuation(self, enabled: object) -> None:
- self.enable_mid_stream_fallback_continuation = self._is_flag_enabled(enabled)
- if self.enable_mid_stream_fallback_continuation:
- self._register_continuation_prefill_check()
-
- def _set_retry_policy(self, value: object) -> None:
- policy: Final = RetryPolicy.model_validate(value) if isinstance(value, dict) else value
- if policy is None or isinstance(policy, RetryPolicy):
- self.retry_policy = policy
-
- def _get_client(self, deployment, kwargs, client_type=None):
- """
- Returns the appropriate client based on the given deployment, kwargs, and client_type.
-
- Parameters:
- deployment (dict): The deployment dictionary containing the clients.
- kwargs (dict): The keyword arguments passed to the function.
- client_type (str): The type of client to return.
-
- Returns:
- The appropriate client based on the given client_type and kwargs.
- """
- model_id: Final = deployment["model_info"]["id"]
- parent_otel_span: Final[Span | None] = get_parent_otel_span_from_kwargs(kwargs)
- if client_type == "max_parallel_requests":
- cache_key = f"{model_id}_max_parallel_requests_client"
- client = self._core_cache.get_cache(key=cache_key, local_only=True, parent_otel_span=parent_otel_span)
- if client is None:
- InitalizeCachedClient.set_max_parallel_requests_client(ctx=self._core_ctx, model=deployment)
- client = self._core_cache.get_cache(key=cache_key, local_only=True, parent_otel_span=parent_otel_span)
- return client
- elif client_type == "async":
- if (
- deployment["litellm_params"].get( # pyright: ignore[reportUnknownMemberType] # legacy mapping
- "kubernetes_pod_discovery"
- )
- is True
- ):
- return None
- if kwargs.get("stream") is True:
- cache_key = f"{model_id}_stream_async_client"
- client = self._core_cache.get_cache(key=cache_key, local_only=True, parent_otel_span=parent_otel_span)
- return client
- else:
- cache_key = f"{model_id}_async_client"
- client = self._core_cache.get_cache(key=cache_key, local_only=True, parent_otel_span=parent_otel_span)
- return client
- else:
- if (
- deployment["litellm_params"].get( # pyright: ignore[reportUnknownMemberType] # legacy mapping
- "kubernetes_pod_discovery"
- )
- is True
- ):
- return None
- if kwargs.get("stream") is True:
- cache_key = f"{model_id}_stream_client"
- client = self._core_cache.get_cache(key=cache_key, parent_otel_span=parent_otel_span)
- return client
- else:
- cache_key = f"{model_id}_client"
- client = self._core_cache.get_cache(key=cache_key, parent_otel_span=parent_otel_span)
- return client
-
- def _count_pre_call_check_tokens(
- self,
- messages: Sequence[Mapping[str, object]] | None,
- input: str | list[object] | None,
- request_kwargs: Mapping[str, object] | None = None,
- tokenizer_model: str = "",
- ) -> int:
- """
- Count input tokens for context-window pre-call checks.
-
- Chat Completions send `messages`; the Responses API sends `input` (a string or
- a list of Responses input items) plus an optional `instructions` system prompt.
- The Responses payload is normalized to chat messages via the shared
- LiteLLMCompletionResponsesConfig transform so the same token_counter path covers
- both API surfaces and `instructions` tokens are included in the count.
-
- Prompt content the message list never carries is read from `request_kwargs`:
- `tools` (Chat Completions, Responses and Anthropic Messages shapes) and the
- Anthropic Messages top-level `system` block.
- """
- from litellm.llms.anthropic.messages.native.utils import (
- anthropic_system_to_openai_message,
- )
-
- extras: Final = request_kwargs if request_kwargs is not None else MappingProxyType({})
- raw_instructions: Final = extras.get("instructions")
- instructions: Final = raw_instructions if isinstance(raw_instructions, str) else None
- raw_tools: Final = extras.get("tools")
- tools: Final = (
- cast(list[ChatCompletionToolParam], raw_tools) # cast-ok: token_counter formats any tool dict shape
- if isinstance(raw_tools, list) and raw_tools
- else None
- )
- system_message: Final = anthropic_system_to_openai_message(extras.get("system"))
- if messages is not None:
- counted_messages: Final = (system_message, *messages) if system_message is not None else messages
- return litellm.token_counter(model=tokenizer_model, messages=counted_messages, tools=tools)
- if input is not None:
- from openai.types.responses.response_create_params import ResponseInputParam
-
- from litellm.responses.litellm_completion_transformation.transformation import (
- LiteLLMCompletionResponsesConfig,
- )
-
- typed_input: Final = cast(str | ResponseInputParam, input) # cast-ok: str | list matches transform input
- input_messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
- input=typed_input,
- responses_api_request={"instructions": instructions} if instructions is not None else {},
- only_since_compaction=True,
- )
- return litellm.token_counter(
- model=tokenizer_model,
- messages=cast(list, input_messages), # cast-ok: transformed chat messages
- tools=tools,
- )
- raise ValueError("Either messages or input must be provided to count tokens")
-
- def _deployment_max_input_tokens(self, model: str, deployment: Mapping[str, object]) -> int | None:
- """The deployment's declared context window, or None when it declares none or cannot be resolved."""
- try:
- model_info: Final = self._core_get_router_model_info(
- deployment=cast(dict, deployment), # cast-ok: router deployments are plain dicts
- received_model_name=model,
- )
- except Exception as e: # noqa: BLE001 # best-effort: an unmappable deployment must not hide the others
- verbose_router_logger.debug(
- "litellm.router.py::_deployment_max_input_tokens: skipping deployment. Got - %s", e
- )
- return None
- max_input_tokens: Final = model_info.get("max_input_tokens")
- return max_input_tokens if isinstance(max_input_tokens, int) else None
-
- def _pre_call_checks_need_token_count(
- self, model: str, healthy_deployments: Sequence[Mapping[str, object]]
- ) -> bool:
- """Whether any healthy deployment declares a context window that a token count could exceed.
-
- Resolves each deployment the way ``_pre_call_checks`` does, so one unmappable deployment
- cannot hide a later one that does declare a limit.
- """
- return any(
- self._deployment_max_input_tokens(model, deployment) is not None for deployment in healthy_deployments
- )
-
- async def _acount_pre_call_check_tokens(
- self,
- model: str,
- healthy_deployments: Sequence[Mapping[str, object]],
- messages: Sequence[Mapping[str, str]] | None,
- input: str | Sequence[object] | None,
- request_kwargs: Mapping[str, object] | None,
- ) -> int | None:
- """Count input tokens off the event loop, so a multi-MB prompt cannot stall the proxy.
-
- Returns None when no deployment limits its context window, and when counting fails. The
- caller pairs this with ``skip_inline_token_count`` so neither case puts the count back on
- the loop: a failed count only skips the context-window check - RPM, region and
- invalid-params checks still run for every deployment.
- """
- if messages is None and input is None:
- return None
- try:
- if not self._pre_call_checks_need_token_count(model, healthy_deployments):
- return None
- return await offload_token_count(self._count_pre_call_check_tokens)(
- messages=cast(list[dict[str, str]] | None, messages), # cast-ok: forwarded to the sync counter
- input=cast(str | list | None, input), # cast-ok: forwarded to the sync counter
- request_kwargs=request_kwargs,
- tokenizer_model=single_encoding_openai_model(
- self.served_openai_models(model) or served_openai_models(healthy_deployments)
- )
- or "",
- )
- except Exception as e: # noqa: BLE001 # best-effort: an uncountable prompt must not fail the request
- verbose_router_logger.error(
- "litellm.router.py::_acount_pre_call_check_tokens: failed to count tokens. Got - %s", e
- )
- return None
-
- def _resolve_pre_call_check_input_tokens(
- self,
- messages: Sequence[Mapping[str, str]] | None,
- input: str | Sequence[object] | None,
- request_kwargs: Mapping[str, object] | None,
- input_token_count: int | None,
- count_inline: bool,
- model: str,
- deployments: Sequence[Mapping[str, object]],
- ) -> int | None:
- if input_token_count is not None:
- return input_token_count
- if not count_inline:
- return None
- try:
- return self._count_pre_call_check_tokens(
- messages=cast(list[dict[str, str]] | None, messages), # cast-ok: forwarded to the sync counter
- input=cast(str | list[object] | None, input), # cast-ok: forwarded to the sync counter
- request_kwargs=request_kwargs,
- tokenizer_model=single_encoding_openai_model(
- self.served_openai_models(model) or served_openai_models(deployments)
- )
- or "",
- )
- except Exception as e:
- verbose_router_logger.error(
- "litellm.router.py::_pre_call_checks: failed to count tokens. "
- "Skipping the context-window check only; RPM/region/param checks still apply. Got - %s",
- e,
- )
- return None
-
- @staticmethod
- def _params_neither_dropped_nor_allowed(params: Mapping[str, object], candidates: frozenset[str]) -> frozenset[str]:
- if params.get("drop_params") is True:
- return frozenset()
- dropped: Final = params.get("additional_drop_params")
- dropped_names: Final = (
- frozenset(entry for entry in dropped if isinstance(entry, str))
- if isinstance(dropped, list)
- else frozenset()
- )
- return candidates - Router._declared_param_allowlist(params) - dropped_names
-
- @staticmethod
- def _deployment_lacks_params(deployment: DeploymentTypedDict, group: str, params: frozenset[str]) -> bool:
- return any(not Router._deployment_supports_param(deployment, group, param) for param in params)
-
- @staticmethod
- def _deployment_rejects_params(
- deployment: DeploymentTypedDict, group: str, params: frozenset[str], request_kwargs: Mapping[str, object]
- ) -> bool:
- call_settings: Final[Mapping[str, object]] = {**(deployment.get("litellm_params") or {}), **request_kwargs}
- return Router._deployment_lacks_params(
- deployment, group, Router._params_neither_dropped_nor_allowed(call_settings, params)
- )
-
- @staticmethod
- def _deployment_declares_no_vision(deployment: DeploymentTypedDict, group: str) -> bool:
- plain_deployment: Final = cast(Mapping[str, object], deployment) # cast-ok: router deployments are plain dicts
- model_info: Final = cast(Mapping[str, object], plain_deployment.get("model_info") or {}) # cast-ok: plain dict
- declared: Final = model_info.get("supports_vision")
- if declared is not None:
- return declared is False
- raw_params: Final = plain_deployment.get("litellm_params") or {}
- params: Final = cast(Mapping[str, object], raw_params) # cast-ok: router deployments are plain dicts
- if not params:
- return False
- base_model: Final = model_info.get("base_model") or params.get("base_model")
- try:
- model, custom_llm_provider = Router._capability_model_and_provider(deployment["litellm_params"], group)
- return litellm.utils.is_vision_explicitly_disabled(
- base_model if isinstance(base_model, str) else model, custom_llm_provider
- )
- except Exception as e: # noqa: BLE001 # best-effort filter: an unresolvable provider must not narrow the request
- verbose_router_logger.debug(
- "litellm.router.py::_deployment_declares_no_vision: keeping deployment for model=%s. Got - %s", group, e
- )
- return False
-
- @staticmethod
- def _deployment_serves_route(
- deployment: DeploymentTypedDict,
- group: str,
- resolver: Callable[[str, LlmProviders], object | None],
- ) -> bool:
- if "litellm_params" not in deployment:
- return True
-
- try:
- model, resolved_custom_llm_provider = Router._capability_model_and_provider(
- deployment["litellm_params"], group
- )
- provider: Final = next(
- (member for member in LlmProviders if member.value == resolved_custom_llm_provider),
- None,
- )
- return provider is not None and resolver(model, provider) is not None
- except Exception as e: # noqa: BLE001 # best-effort capability filter
- verbose_router_logger.debug(
- "litellm.router.py::_deployment_serves_route: keeping deployment for model=%s. Got - %s", group, e
- )
- return True
-
- @staticmethod
- def _without_deployments_that_cannot_serve_route(
- healthy_deployments: _RouteDeploymentsT, group: str
- ) -> _RouteDeploymentsT:
- handler_name: Final = _routed_call_type.get()
- resolver: Final = _ROUTE_CAPABILITY_RESOLVERS.get(handler_name) if handler_name is not None else None
- if resolver is None or isinstance(healthy_deployments, Mapping):
- return healthy_deployments
-
- filtered_deployments: Final = [
- deployment
- for deployment in healthy_deployments
- if Router._deployment_serves_route(
- cast(DeploymentTypedDict, deployment), # cast-ok: router candidates use the deployment shape
- group,
- resolver,
- )
- ]
- if not filtered_deployments:
- return healthy_deployments
- return cast(_RouteDeploymentsT, filtered_deployments) # cast-ok: same list shape as the input candidates
-
- @staticmethod
- def _decisions_deployment_refuses_media(
- deployment: DeploymentTypedDict, group: str, request_kwargs: Mapping[str, object], *, audio: bool, videos: bool
- ) -> bool:
- from litellm.decisions.call import deployment_refuses_media
-
- plain_deployment: Final = cast(Mapping[str, object], deployment) # cast-ok: router deployments are plain dicts
- model_info: Final = cast(Mapping[str, object], plain_deployment.get("model_info") or {}) # cast-ok: plain dict
- deployment_params: Final = cast(Mapping[str, object], deployment["litellm_params"]) # cast-ok: plain dict
- try:
- model, custom_llm_provider = Router._capability_model_and_provider(deployment["litellm_params"], group)
- return deployment_refuses_media(
- model,
- custom_llm_provider,
- model_info,
- {**deployment_params, **request_kwargs},
- audio=audio,
- videos=videos,
- )
- except Exception as e: # noqa: BLE001 # best-effort filter: a deployment it cannot resolve answers for itself
- verbose_router_logger.debug(
- "litellm.router.py::_decisions_deployment_refuses_media: keeping a deployment of model=%s. Got - %s",
- group,
- e,
- )
- return False
-
- def _without_decisions_deployments_refusing_media(
- self, healthy_deployments: _RouteDeploymentsT, group: str, request_kwargs: Mapping[str, object] | None
- ) -> _RouteDeploymentsT:
- call_kwargs: Final = request_kwargs or {}
- audio: Final = bool(call_kwargs.get("audio"))
- videos: Final = bool(call_kwargs.get("videos"))
- if (
- _routed_call_type.get() not in _DECISIONS_MEDIA_CALL_TYPES
- or not (audio or videos)
- or isinstance(healthy_deployments, Mapping)
- ):
- return healthy_deployments
-
- sent_kwargs: Final = {**self._active_default_litellm_params(), **call_kwargs}
- deployments_taking_media: Final = [
- deployment
- for deployment in healthy_deployments
- if not Router._decisions_deployment_refuses_media(
- cast(DeploymentTypedDict, deployment), # cast-ok: router candidates use the deployment shape
- group,
- sent_kwargs,
- audio=audio,
- videos=videos,
- )
- ]
- if not deployments_taking_media:
- return healthy_deployments
- return cast(_RouteDeploymentsT, deployments_taking_media) # cast-ok: same list shape as the input candidates
-
- @staticmethod
- def _without_declared_text_only_deployments(
- healthy_deployments: _RouteDeploymentsT,
- group: str,
- messages: list[dict[str, str]] | None, # mutable-ok: handed to _request_carries_image as-is
- request_kwargs: Mapping[str, object] | None,
- ) -> _RouteDeploymentsT:
- if isinstance(healthy_deployments, Mapping) or not Router._request_carries_image(
- messages, request_kwargs or {}
- ):
- return healthy_deployments
-
- vision_capable_deployments: Final = [
- deployment
- for deployment in healthy_deployments
- if not Router._deployment_declares_no_vision(
- cast(DeploymentTypedDict, deployment), # cast-ok: router candidates use the deployment shape
- group,
- )
- ]
- if not vision_capable_deployments:
- return healthy_deployments
- return cast(_RouteDeploymentsT, vision_capable_deployments) # cast-ok: same list shape as the input candidates
-
- @staticmethod
- def _request_carries_image(
- messages: object,
- request_kwargs: Mapping[str, object],
- ) -> bool:
- from litellm.litellm_core_utils.prompt_templates.common_utils import request_contains_image_content
-
- return request_contains_image_content(messages or request_kwargs.get("messages") or request_kwargs.get("input"))
-
- @staticmethod
- def _pre_call_check_requested_chat_params(request_kwargs: Mapping[str, object]) -> frozenset[str]:
- requested: Final = litellm.utils.get_non_default_params(passed_params=dict(request_kwargs))
- return frozenset(
- param
- for param, value in requested.items()
- if param in _PRE_CALL_CHECK_FILTERED_PARAMS and not isinstance(value, str)
- )
-
- def _pre_call_checks(
- self,
- model: str,
- healthy_deployments: list,
- messages: list[dict[str, str]] | None = None,
- input: str | list | None = None,
- request_kwargs: dict | None = None,
- input_token_count: int | None = None,
- skip_inline_token_count: bool = False,
- request_deployments: Sequence[DeploymentTypedDict] | None = None,
- ):
- """
- Filter out model in model group, if:
-
- - model context window < message length. For azure openai models, requires 'base_model' is set. - https://docs.litellm.ai/docs/proxy/cost_tracking#spend-tracking-for-azure-openai-models
- - if region given, filter out models not in that region / unknown region
- - [TODO] function call and model doesn't support function calling
- """
-
- verbose_router_logger.debug("Starting Pre-call checks for deployments in model=%s", model)
-
- # Optimized: Use list() shallow copy instead of deepcopy
- # We only pop from the list, not modify deployment dicts - 100x+ faster on hot path (every request)
- _returned_deployments = list(healthy_deployments)
-
- invalid_model_indices: Final = set[int]() # Use set for O(1) membership checks
-
- _context_window_error = False
- _potential_error_str = ""
-
- has_countable_input: Final = (messages is not None or input is not None) and not compaction_pending(
- request_kwargs
- )
- deployment_max_input_tokens: Final = tuple(
- self._deployment_max_input_tokens(model, deployment) for deployment in _returned_deployments
- )
- input_tokens: Final = self._resolve_pre_call_check_input_tokens(
- messages=messages,
- input=cast(str | list[object] | None, input), # cast-ok: forwarded to the resolver
- request_kwargs=cast(Mapping[str, object] | None, request_kwargs), # cast-ok: forwarded to the resolver
- input_token_count=input_token_count,
- count_inline=has_countable_input
- and not skip_inline_token_count
- and any(limit is not None for limit in deployment_max_input_tokens),
- model=model,
- deployments=_returned_deployments,
- )
- request_settings: Final[Mapping[str, object]] = request_kwargs if request_kwargs is not None else {}
- requested_params: Final[frozenset[str]] = (
- self._pre_call_check_requested_chat_params(request_settings)
- if request_kwargs is not None and litellm.drop_params is False
- else frozenset()
- )
-
- for idx, deployment in enumerate(_returned_deployments):
- # Cache nested dict access to avoid repeated temporary dict allocations
- _litellm_params = deployment.get("litellm_params", {})
- _model_info = deployment.get("model_info", {})
-
- max_input_tokens = deployment_max_input_tokens[idx]
- if (
- max_input_tokens is not None
- and has_countable_input
- and input_tokens is not None
- and input_tokens > max_input_tokens
- ):
- invalid_model_indices.add(idx)
- _context_window_error = True
- deployment_model = (
- _model_info.get("base_model") or _litellm_params.get("base_model") or _litellm_params.get("model")
- )
- _potential_error_str += (
- f"Model={deployment_model}, Max Input Tokens={max_input_tokens}, Got={input_tokens}"
- )
- continue
-
- ## REGION CHECK ##
- if request_kwargs is not None and request_kwargs.get("allowed_model_region") is not None:
- allowed_model_region = request_kwargs.get("allowed_model_region")
-
- if allowed_model_region is not None:
- if not is_region_allowed(
- litellm_params=LiteLLM_Params.model_validate(_litellm_params),
- allowed_model_region=allowed_model_region,
- ):
- invalid_model_indices.add(idx)
- continue
-
- typed_deployments: Final = tuple(
- cast(DeploymentTypedDict, deployment) # cast-ok: router deployments are plain dicts
- for deployment in _returned_deployments
- )
- param_candidates: Final = tuple(
- (idx, deployment) for idx, deployment in enumerate(typed_deployments) if idx not in invalid_model_indices
- )
- candidates_lacking_params: Final = tuple(
- self._deployment_lacks_params(deployment, model, requested_params) for _, deployment in param_candidates
- )
- no_deployment_takes_params: Final = bool(candidates_lacking_params) and all(candidates_lacking_params)
- candidates_failing_params: Final = (
- tuple(
- self._deployment_rejects_params(deployment, model, requested_params, request_settings)
- for _, deployment in param_candidates
- )
- if no_deployment_takes_params
- else candidates_lacking_params
- )
- excluded_indices: Final = frozenset(invalid_model_indices) | frozenset(
- idx for (idx, _), fails in zip(param_candidates, candidates_failing_params, strict=True) if fails
- )
-
- if len(excluded_indices) == len(_returned_deployments):
- group_deployments: Final = (
- tuple(request_deployments)
- if request_deployments is not None
- else (tuple(self._core_get_all_deployments(model_name=model)) or typed_deployments)
- )
- if (
- not _context_window_error
- and group_deployments
- and all(
- self._deployment_rejects_params(deployment, model, requested_params, request_settings)
- for deployment in group_deployments
- )
- ):
- raise litellm.UnsupportedParamsError(
- message=(
- f"no deployment of model={model} supports {sorted(requested_params)}. "
- "To drop these, set `litellm.drop_params=True` or for proxy `litellm_settings: drop_params: true`, "
- f"or send allowed_openai_params={sorted(requested_params)} to pass them through"
- ),
- model=model,
- )
- if _context_window_error is True:
- raise litellm.ContextWindowExceededError(
- message=f"litellm._pre_call_checks: Context Window exceeded for given call. No models have context window large enough for this call.\n{_potential_error_str}",
- model=model,
- llm_provider="",
- )
- if len(excluded_indices) > 0:
- # Single-pass filter using set for O(1) lookups (avoids O(n^2) from repeated pops)
- _returned_deployments = [d for i, d in enumerate(_returned_deployments) if i not in excluded_indices]
-
- return _returned_deployments
-
- def _core_get_model_from_alias(self, model: str) -> str | None:
- """
- Get the model from the alias.
-
- Returns:
- - str, the litellm model name
- - None, if model is not in model group alias
- """
- return resolve_model_group_alias(self._core_model_group_alias, model)
-
- def _get_deployment_by_litellm_model(self, model: str) -> list[DeploymentTypedDict]:
- """
- Get the deployment by litellm model.
- """
- return [m for m in self._core_model_list if m["litellm_params"]["model"] == model]
-
- def _core_get_routable_deployments(
- self,
- model: str,
- request_kwargs: Mapping[str, object] = MappingProxyType({}),
- call_type: str | None = None,
- ) -> tuple[DeploymentTypedDict, ...]:
- try:
- with endpoint_selection(call_type, model=model):
- _, candidates = self._common_checks_available_deployment(model=model, request_kwargs=request_kwargs) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # its signature leaves input and the result untyped
- except (litellm.BadRequestError, ValueError):
- return ()
- if isinstance(candidates, dict):
- return (cast(DeploymentTypedDict, candidates),) # cast-ok: the router returns one deployment as a dict
- return tuple(cast(list[DeploymentTypedDict], candidates)) # cast-ok: the router returns deployments as a list
-
- def _try_early_resolve_deployments_for_model_not_in_names(
- self,
- model: str,
- request_team_id: str | None,
- include_team_models: bool = False,
- ) -> tuple[str, list | dict] | None:
- """
- When ``model`` is not in ``self._core_model_names``, try team routes, pattern routes,
- team pattern routers, then default deployment. Returns None if none apply.
- """
- if model in self._core_model_names:
- return None
- # Check for team-specific deployments by team_public_model_name.
- # This intentionally takes priority over team pattern routers below,
- # so that named team deployments shadow wildcard/pattern routes.
- if request_team_id is not None:
- team_deployments = self._core_get_all_deployments(model_name=model, team_id=request_team_id)
- if team_deployments:
- return model, team_deployments
- elif include_team_models:
- team_deployments = self._team_deployments_across_teams(model)
- if team_deployments:
- return model, team_deployments
-
- wildcard_deployments: Final = self._core_wildcard_route_deployments(model, request_team_id)
- if wildcard_deployments:
- return model, list(wildcard_deployments)
-
- if self._core_default_deployment is not None:
- # Shallow copy with nested litellm_params copy (100x+ faster than deepcopy)
- updated_deployment: Final = self._core_default_deployment.copy()
- updated_deployment["litellm_params"] = self._core_default_deployment["litellm_params"].copy()
- updated_deployment["litellm_params"]["model"] = model
- return model, updated_deployment
-
- return None
-
- def _team_deployments_across_teams(self, model: str) -> list[DeploymentTypedDict]:
- """Every team's deployments under public name `model`, for a proxy admin calling without a team."""
- with self._deployment_index_lock:
- team_deployments: Final = [
- self._core_model_list[index]
- for (_, public_model_name), indices in self._core_team_model_to_deployment_indices.items()
- if public_model_name == model
- for index in indices
- ]
- team_ids: Final = {
- team_id
- for deployment in team_deployments
- for team_id in [(deployment.get("model_info") or {}).get("team_id")]
- if team_id is not None
- }
- if len(team_ids) > 1:
- raise litellm.BadRequestError(
- message=(
- f"Model name '{model}' matches deployments from multiple teams. "
- "Specify the deployment ID directly to disambiguate."
- ),
- model=model,
- llm_provider="",
- )
- return team_deployments
-
- def _core_deployments_for_request(
- self, model: str, request_kwargs: Mapping[str, object]
- ) -> Sequence[DeploymentTypedDict]:
- """The deployments `model` names for this caller, through the same alias, then team-first, then
- global, then admin-across-teams resolution `_common_checks_available_deployment` applies, so
- strategy selection and compression policy can never disagree with deployment selection about
- which marker a name means."""
- registered_name: Final = self._core_get_model_from_alias(model=model) or model
- team_id: Final = get_request_team_id(request_kwargs)
- deployments: Final = self._core_get_all_deployments(model_name=registered_name, team_id=team_id)
- if deployments or team_id is not None or not is_proxy_admin_request(request_kwargs):
- return deployments
- return self._team_deployments_across_teams(registered_name)
-
- @staticmethod
- def _is_strategy_marker_deployment(deployment: Mapping[str, object]) -> bool:
- litellm_params: Final = deployment.get("litellm_params")
- if not isinstance(litellm_params, Mapping):
- return False
- deployment_model: Final = litellm_params.get("model")
- return isinstance(deployment_model, str) and classify_strategy_router_model(deployment_model) is not None
-
- def _common_checks_available_deployment(
- self,
- model: str,
- messages: list[dict[str, str]] | None = None,
- input: str | list | None = None,
- specific_deployment: bool | None = False,
- request_kwargs: Mapping[str, object] | None = None,
- ) -> tuple[str, list | dict]:
- """
- Common checks for 'get_available_deployment' across sync + async call.
-
- If 'healthy_deployments' returned is None, this means the user chose a specific deployment
-
- Returns
- - str, the litellm model name
- - List, if multiple models chosen
- - Dict, if specific model chosen
- """
-
- request_team_id: Final = get_request_team_id(request_kwargs)
- # check if aliases set on litellm model alias map
- if specific_deployment is True:
- return model, self._drop_strategy_markers(
- model,
- self._filter_reserved_deployments(
- model=model,
- healthy_deployments=self._get_deployment_by_litellm_model(model=model),
- request_team_id=request_team_id,
- ),
- )
- elif model not in self._core_model_names and self._core_has_model_id(model):
- deployment: Final = self._core_get_deployment(model_id=model)
- if deployment is not None:
- deployment_model: Final = deployment.litellm_params.model
- return deployment_model, cast( # cast-ok: contract requires a plain dict for a single deployment
- dict,
- self._filter_reserved_deployments(
- model=deployment_model,
- healthy_deployments=(
- cast( # cast-ok: model_dump of a router deployment
- DeploymentTypedDict, deployment.model_dump(exclude_none=True)
- ),
- ),
- request_team_id=request_team_id,
- )[0],
- )
- raise ValueError(
- f"LiteLLM Router: Trying to call specific deployment, but Model ID :{model} does not exist in Model ID map"
- )
-
- _model_from_alias: Final = self._core_get_model_from_alias(model=model)
- if _model_from_alias is not None:
- model = _model_from_alias
-
- _routing_group_deployments: Final = self._get_routing_group_deployments(model=model, team_id=request_team_id)
- if _routing_group_deployments is None:
- early: Final = self._try_early_resolve_deployments_for_model_not_in_names(
- model=model,
- request_team_id=request_team_id,
- include_team_models=is_proxy_admin_request(request_kwargs),
- )
- if early is not None:
- if not isinstance(early[1], list):
- return early[0], cast( # cast-ok: contract requires a plain dict for a single deployment
- dict,
- self._filter_reserved_deployments(
- model=early[0],
- healthy_deployments=(
- cast( # cast-ok: early resolve returns a router deployment
- DeploymentTypedDict, early[1]
- ),
- ),
- request_team_id=request_team_id,
- )[0],
- )
- return early[0], self._drop_strategy_markers(
- early[0],
- self._filter_reserved_deployments(
- model=early[0],
- healthy_deployments=early[1],
- request_team_id=request_team_id,
- ),
- )
-
- ## get healthy deployments
- ### get all deployments
- healthy_deployments = (
- _routing_group_deployments
- if _routing_group_deployments is not None
- else self._core_get_all_deployments(model_name=model, team_id=request_team_id)
- )
- _pre_model_access_group_filter_len: Final = len(healthy_deployments)
- healthy_deployments = self._filter_reserved_deployments(
- model=model,
- healthy_deployments=self._filter_deployments_by_model_access_groups(
- model=model,
- healthy_deployments=healthy_deployments,
- request_kwargs=request_kwargs,
- request_team_id=request_team_id,
- ),
- request_team_id=request_team_id,
- )
- _access_group_filter_emptied_candidates = (
- _pre_model_access_group_filter_len > 0 and len(healthy_deployments) == 0
- )
-
- if len(healthy_deployments) == 0:
- # check if the user sent in a deployment name instead
- # Do not fall back when access-group filtering removed every candidate;
- # _get_deployment_by_litellm_model does not re-apply that filter.
- if _pre_model_access_group_filter_len == 0:
- _litellm_model_deployments: Final = self._get_deployment_by_litellm_model(model=model)
- healthy_deployments = self._filter_reserved_deployments(
- model=model,
- healthy_deployments=self._filter_deployments_by_model_access_groups(
- model=model,
- healthy_deployments=_litellm_model_deployments,
- request_kwargs=request_kwargs,
- request_team_id=request_team_id,
- ),
- request_team_id=request_team_id,
- )
- # If the litellm-model lookup produced candidates that access-group
- # filtering then removed, treat this the same as the by-name path
- # being emptied: prevent default-model fallback from bypassing the
- # restriction (the fallback model may have no access_groups and
- # would short-circuit the filter).
- if len(_litellm_model_deployments) > 0 and len(healthy_deployments) == 0:
- _access_group_filter_emptied_candidates = True
-
- if verbose_router_logger.isEnabledFor(logging.DEBUG):
- verbose_router_logger.debug("initial list of deployments: %s", healthy_deployments)
-
- if len(healthy_deployments) == 0:
- # Check for default fallbacks if no deployments are found for the requested model
- # Do not fall back to another model when access-group filtering removed every
- # candidate for the requested name: re-filtering the fallback model can be a
- # no-op when it has no access_groups, incorrectly serving a different model.
- if self._has_default_fallbacks() and not _access_group_filter_emptied_candidates:
- fallback_model: Final = self._get_first_default_fallback()
- if fallback_model:
- verbose_router_logger.info(
- "Model '%s' not found. Attempting to use default fallback model '%s'.", model, fallback_model
- )
- # Re-assign model to the fallback and try to get deployments again
- model = fallback_model
- healthy_deployments = self._core_get_all_deployments(model_name=model, team_id=request_team_id)
- healthy_deployments = self._filter_reserved_deployments(
- model=model,
- healthy_deployments=self._filter_deployments_by_model_access_groups(
- model=model,
- healthy_deployments=healthy_deployments,
- request_kwargs=request_kwargs,
- request_team_id=request_team_id,
- ),
- request_team_id=request_team_id,
- )
-
- # If still no deployments after checking for fallbacks, raise an error
- if len(healthy_deployments) == 0:
- raise litellm.BadRequestError(
- message=f"You passed in model={model}. {RouterErrors.no_healthy_deployments.value}",
- model=model,
- llm_provider="",
- )
-
- if litellm.model_alias_map and model in litellm.model_alias_map:
- model = litellm.model_alias_map[
- model
- ] # update the model to the actual value if an alias has been passed in
-
- return model, self._drop_strategy_markers(model, healthy_deployments)
-
- def _drop_strategy_markers(
- self, model: str, deployments: Sequence[DeploymentTypedDict]
- ) -> list[DeploymentTypedDict]:
- """A strategy marker is never a callable deployment, whichever resolution arm produced it."""
- selectable: Final = [d for d in deployments if not self._is_strategy_marker_deployment(d)]
- if deployments and not selectable:
- raise litellm.BadRequestError(
- message=f"You passed in model={model}. {RouterErrors.only_strategy_marker_deployments.value}",
- model=model,
- llm_provider="",
- )
- eligible: Final = eligible_deployments(model, selectable)
- if isinstance(eligible, tuple):
- return list(eligible)
- raise eligible
-
- def _filter_reserved_deployments(
- self,
- model: str,
- healthy_deployments: Sequence[DeploymentTypedDict],
- request_team_id: str | None,
- ) -> tuple[DeploymentTypedDict, ...]:
- result: Final = filter_reserved_deployments(
- self._drop_strategy_markers(model, healthy_deployments), request_team_id, now=datetime.now(timezone.utc)
- )
- if result.blocking_window is not None and len(result.deployments) == 0:
- raise litellm.BadRequestError(
- message=f"Deployment {model} is reserved for another team until "
- f"{result.blocking_window.end:%H:%M} {result.blocking_window.timezone}",
- model=model,
- llm_provider="",
- )
- shared: Final = filter_ptu_shared_deployments(result.deployments, request_team_id)
- if shared.withheld and len(shared.deployments) == 0:
- raise litellm.BadRequestError(
- message=f"Deployment {model} is reserved for the teams holding a PTU share of it",
- model=model,
- llm_provider="",
- )
- return shared.deployments
-
- def _filter_deployments_by_model_access_groups(
- self,
- model: str,
- healthy_deployments: list[DeploymentTypedDict],
- request_kwargs: Mapping[str, object] | None,
- request_team_id: str | None,
- ) -> list[DeploymentTypedDict]:
- """
- Restrict candidate deployments to caller-authorized model access groups.
-
- This is only applied when:
- - request metadata includes `user_api_key_auth`, and
- - caller permissions for this model are access-group-only
- (no explicit model, wildcard, or all-proxy grants).
- """
- if not healthy_deployments or request_kwargs is None:
- return healthy_deployments
-
- user_api_key_auth: Final = request_user_api_key_auth(request_kwargs)
- if user_api_key_auth is None:
- return healthy_deployments
-
- object_models: Final = set(getattr(user_api_key_auth, "models", []) or [])
- object_team_models: Final = set(getattr(user_api_key_auth, "team_models", []) or [])
- allowed_models: Final = object_models | object_team_models
- if not allowed_models:
- return healthy_deployments
-
- # If caller has direct model/wildcard/all-proxy access, do not constrain
- # deployment choice by access group.
- if model in allowed_models or "*" in allowed_models or "all-proxy-models" in allowed_models:
- return healthy_deployments
-
- access_groups_for_model: Final = self._core_get_model_access_groups(model_name=model, team_id=request_team_id)
- if len(access_groups_for_model) == 0:
- return healthy_deployments
-
- allowed_access_groups: Final = set(access_groups_for_model.keys()) & allowed_models
- if not allowed_access_groups:
- # No overlap means this request was not authorized via model access
- # group membership for this model, so do not force group filtering.
- return healthy_deployments
-
- filtered_deployments: Final = []
- for deployment in healthy_deployments:
- deployment_model_info = deployment.get("model_info") or {}
- deployment_access_groups = set(deployment_model_info.get("access_groups", []) or [])
- if deployment_access_groups & allowed_access_groups:
- filtered_deployments.append(deployment)
-
- return filtered_deployments
-
- async def _core_async_get_healthy_deployments(
- self,
- model: str,
- request_kwargs: dict,
- messages: list[dict[str, str]] | None = None,
- input: str | list | None = None,
- specific_deployment: bool | None = False,
- parent_otel_span: Span | None = None,
- health_check_probe: bool = False,
- ) -> list[dict] | dict:
- """
- Get the healthy deployments for a model.
-
- Returns:
- - List[Dict], if multiple models chosen
- *OR*
- - Dict, if specific model chosen
- """
-
- model, healthy_deployments = self._common_checks_available_deployment(
- model=model,
- messages=messages,
- input=input,
- specific_deployment=specific_deployment,
- request_kwargs=request_kwargs,
- )
-
- # IF TEAM ID SPECIFIED ON MODEL, AND REQUEST CONTAINS USER_API_KEY_TEAM_ID, FILTER OUT MODELS THAT ARE NOT IN THE TEAM
- ## THIS PREVENTS WRITING FILES OF OTHER TEAMS TO MODELS THAT ARE TEAM-ONLY MODELS
- healthy_deployments = filter_team_based_models(
- healthy_deployments=healthy_deployments,
- request_kwargs=request_kwargs,
- )
-
- if verbose_router_logger.isEnabledFor(logging.DEBUG):
- verbose_router_logger.debug("healthy_deployments after team filter: %s", healthy_deployments)
-
- healthy_deployments = filter_web_search_deployments(
- healthy_deployments=healthy_deployments,
- request_kwargs=request_kwargs,
- )
-
- if verbose_router_logger.isEnabledFor(logging.DEBUG):
- verbose_router_logger.debug("healthy_deployments after web search filter: %s", healthy_deployments)
-
- healthy_deployments = ( # pyright: ignore[reportUnknownVariableType] # rebind-ok: route filter narrows
- self._without_deployments_that_cannot_serve_route(
- healthy_deployments, # pyright: ignore[reportUnknownArgumentType] # legacy selector type
- model,
- )
- )
-
- if isinstance(healthy_deployments, dict):
- if (healthy_deployments.get("model_info") or {}).get("blocked") is True:
- raise litellm.ServiceUnavailableError(
- message=f"Model '{model}' is currently paused and cannot accept requests.",
- model=model,
- llm_provider="",
- )
- return healthy_deployments
-
- request_deployments: Final = cast( # cast-ok: router deployments are plain dicts
- Sequence[DeploymentTypedDict], healthy_deployments
- )
-
- # Health-check-based filtering (before cooldown)
- healthy_deployments = ( # rebind-ok: filtered candidates enter health checks
- await self._async_filter_health_check_unhealthy_deployments(
- healthy_deployments=healthy_deployments,
- parent_otel_span=parent_otel_span,
- health_check_probe=health_check_probe,
- )
- )
-
- routing_read_batch: Final = RoutingReadBatch.active()
- cooldown_deployments: Final = (
- await async_get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
- if routing_read_batch is None
- else await routing_read_batch.async_get_cooldown_deployments(
- ctx=self._core_ctx,
- healthy_deployments=healthy_deployments,
- parent_otel_span=parent_otel_span,
- )
- )
- if verbose_router_logger.isEnabledFor(logging.DEBUG):
- verbose_router_logger.debug("cooldown deployments: %s", cooldown_deployments)
- _pre_cooldown_deployments: Final = healthy_deployments
- healthy_deployments = self._filter_cooldown_deployments(
- healthy_deployments=healthy_deployments,
- cooldown_deployments=cooldown_deployments,
- )
- # Safety net: only bypass cooldown filter when health-check routing is
- # driving cooldown (i.e. allowed_fails_policy is set). Without a policy,
- # cooldowns are from real request failures and must not be bypassed.
- if (
- not healthy_deployments
- and self._core_enable_health_check_routing
- and self._core_allowed_fails_policy is not None
- ):
- verbose_router_logger.warning(
- "All deployments in cooldown via health-check routing, bypassing cooldown filter"
- )
- healthy_deployments = _pre_cooldown_deployments
-
- healthy_deployments = self._filter_blocked_deployments(healthy_deployments)
-
- healthy_deployments = await self.async_callback_filter_deployments(
- model=model,
- healthy_deployments=healthy_deployments,
- messages=(cast(list[AllMessageValues], messages) if messages is not None else None),
- request_kwargs=request_kwargs,
- parent_otel_span=parent_otel_span,
- )
-
- if self._core_enable_pre_call_checks and (messages is not None or input is not None):
- deployments_to_check: Final = healthy_deployments
- healthy_deployments = self._pre_call_checks(
- model=model,
- healthy_deployments=deployments_to_check,
- messages=messages,
- input=input,
- request_kwargs=request_kwargs,
- input_token_count=await self._acount_pre_call_check_tokens(
- model=model,
- healthy_deployments=deployments_to_check,
- messages=messages,
- input=input,
- request_kwargs=request_kwargs,
- ),
- skip_inline_token_count=True,
- request_deployments=request_deployments,
- )
- # check if user wants to do tag based routing
- healthy_deployments = await get_deployments_for_tag(
- ctx=self._core_ctx,
- model=model,
- request_kwargs=request_kwargs,
- healthy_deployments=healthy_deployments,
- metadata_variable_name=self._get_metadata_variable_name_from_kwargs(request_kwargs),
- request_deployments=request_deployments,
- )
-
- # narrow to whatever `self._core_routing_plugins` left in candidate_models
- healthy_deployments = self._filter_by_routing_plugin_candidates(
- healthy_deployments=healthy_deployments,
- request_kwargs=request_kwargs,
- )
-
- ## ORDER FILTERING ## -> if user set 'order' in deployments, return deployments with lowest order (e.g. order=1 > order=2)
- _target_order: Final = (request_kwargs or {}).pop("_target_order", None)
- batch_candidates: Final = self._without_decisions_deployments_refusing_media(
- self._without_declared_text_only_deployments(
- cast(list[dict[str, object]], healthy_deployments), # cast-ok: router deployments are plain dicts
- model,
- messages,
- cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict
- ),
- model,
- cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict
- )
- embedding_batch_admitting: Final = deployments_admitting_embedding_batch(
- batch_candidates,
- _OPAQUE_VALUE_ADAPTER.validate_python(input),
- _OPAQUE_VALUE_ADAPTER.validate_python(request_kwargs),
- )
- embedding_batch_routed: Final = (
- embedding_batch_admitting
- if embedding_batch_admitting is not None
- else deployments_when_none_admits_embedding_batch(
- batch_candidates,
- cast(list[dict[str, object]], _pre_cooldown_deployments), # cast-ok: router deployments are plain dicts
- await self._async_routable_cooled_deployment_ids(
- model=model,
- request_deployments=request_deployments,
- cooldown_deployments=cooldown_deployments,
- request_kwargs=cast(Mapping[str, object], request_kwargs), # cast-ok: str-keyed request kwargs
- has_request_input=True,
- ),
- _OPAQUE_VALUE_ADAPTER.validate_python(input),
- _OPAQUE_VALUE_ADAPTER.validate_python(request_kwargs),
- )
- )
- healthy_deployments = litellm.utils.get_order_filtered_deployments(
- list(
- self._narrow_to_video_deployment(
- without_deployments_that_cannot_batch(
- embedding_batch_routed, caller_deployments=lambda: batch_candidates
- )
- )
- ),
- target_order=_target_order,
- )
-
- ## WEIGHTED FAILOVER EXCLUSION ## -> drop deployments already tried in
- ## this request via weighted-failover. Always honored, regardless of the
- ## router-level flag, so a stale exclusion key on kwargs cannot escape.
- _excluded_deployment_ids: Final = (request_kwargs or {}).pop("_excluded_deployment_ids", None)
- healthy_deployments = litellm.utils.get_excluded_filtered_deployments(
- cast(list[dict], healthy_deployments),
- excluded_deployment_ids=_excluded_deployment_ids,
- )
-
- ## RETRY SKIP ## -> drop deployments that already refused this request with a
- ## non-retryable status, unless that leaves nothing, so the caller still gets
- ## the provider's own error instead of a no-deployments error.
- _retry_skipped_deployment_ids: Final = as_retry_skipped_deployment_ids(
- request_kwargs.pop("_retry_skipped_deployment_ids", None) if request_kwargs else None
- )
- healthy_deployments = (
- litellm.utils.get_excluded_filtered_deployments(
- healthy_deployments, excluded_deployment_ids=_retry_skipped_deployment_ids
- )
- or healthy_deployments
- )
-
- if len(healthy_deployments) == 0:
- exception: Final = await async_raise_no_deployment_exception(
- ctx=self._core_ctx,
- model=model,
- parent_otel_span=parent_otel_span,
- route_model_ids=await self._async_routable_cooled_deployment_ids(
- model=model,
- request_deployments=request_deployments,
- cooldown_deployments=cooldown_deployments,
- request_kwargs=cast(Mapping[str, object], request_kwargs), # cast-ok: str-keyed request kwargs
- has_request_input=messages is not None or input is not None,
- ),
- )
- raise exception
-
- return healthy_deployments
-
- @staticmethod
- def _pop_effort_from_nested_carrier(request_kwargs: dict[str, object], carrier: str) -> None:
- nested: Final = request_kwargs.get(carrier)
- if not isinstance(nested, Mapping):
- return
- sanitized: Final = {key: value for key, value in nested.items() if key != "effort"}
- if sanitized:
- request_kwargs[carrier] = sanitized # rebind-ok: copy-on-write, so a shared nested carrier is never edited
- else:
- request_kwargs.pop(carrier, None)
-
- @staticmethod
- def _tier_ceiling_under_the_surface_name(
- tier_litellm_params: Mapping[str, object], responses_call: bool
- ) -> Mapping[str, object]:
- """``max_tokens``, ``max_completion_tokens`` and ``max_output_tokens`` are one
- ceiling under three names, and each surface reads exactly one of them: the
- Responses bridge builds its internal ``max_tokens`` from ``max_output_tokens``
- and would overwrite the tier's, chat and /v1/messages never read
- ``max_output_tokens``, and litellm already renames ``max_tokens`` to
- ``max_completion_tokens`` for the OpenAI models that require it. Collapse
- whatever the tier carries onto the surface's own name, preferring a value the
- operator already wrote under that name."""
- surface_key: Final = "max_output_tokens" if responses_call else "max_tokens"
- carried: Final = tuple(
- key
- for key in (surface_key, "max_tokens", "max_completion_tokens", "max_output_tokens")
- if key in tier_litellm_params
- )
- if not carried:
- return tier_litellm_params
- return MappingProxyType(
- {
- **{k: v for k, v in tier_litellm_params.items() if k not in OUTPUT_TOKEN_CEILING_PARAMS},
- surface_key: tier_litellm_params[carried[0]],
- }
- )
-
- def _pin_tier_params_onto_request(
- self,
- model: str,
- tier_litellm_params: Mapping[str, object] | None,
- request_kwargs: dict,
- responses_call: bool,
- ) -> bool:
- """Apply per-tier litellm_params and report whether they pinned an output ceiling."""
- if not tier_litellm_params:
- return False
- reject_api_key_secret_reference(tier_litellm_params)
- accepted_tier_params: Final = self._tier_params_the_target_accepts(model, tier_litellm_params, request_kwargs)
- surface_tier_params: Final = self._tier_ceiling_under_the_surface_name(
- accepted_tier_params, responses_call=responses_call
- )
- self._drop_client_carriers_a_tier_pin_supersedes(request_kwargs, surface_tier_params)
- request_kwargs.update(surface_tier_params)
- return not OUTPUT_TOKEN_CEILING_PARAMS.isdisjoint(surface_tier_params)
-
- @staticmethod
- def _restore_client_output_ceiling(request_kwargs: MutableMapping[str, object]) -> None:
- """Restore caller intent before routing, so neither a previous tier pin nor alias defaults
- become the next attempt's budget. The proxy strips caller-written stamps at ingestion."""
- callers_ceiling: Final = stamped_client_ceiling(request_kwargs)
- if callers_ceiling is None:
- return
- for carrier in OUTPUT_TOKEN_CEILING_PARAMS:
- request_kwargs.pop(carrier, None)
- request_kwargs.update(callers_ceiling)
-
- @staticmethod
- def _stamp_client_output_ceiling(
- request_kwargs: dict[str, object], # mutable-ok: preserve request-owned metadata across routing attempts
- ) -> None:
- _, metadata_bucket = get_or_create_metadata_bucket(request_kwargs)
- metadata_bucket.setdefault(
- CLIENT_OUTPUT_CEILING_METADATA_KEY,
- {carrier: request_kwargs[carrier] for carrier in OUTPUT_TOKEN_CEILING_PARAMS if carrier in request_kwargs},
- )
-
- @staticmethod
- def _drop_client_carriers_a_tier_pin_supersedes(
- request_kwargs: dict[str, object],
- tier_litellm_params: Mapping[str, object],
- ) -> None:
- """Tier litellm_params are deliberate operator overrides, but provider
- translations let a caller-supplied carrier of the same setting
- (``thinking``, ``output_config.effort``, ``reasoning.effort``) outrank
- the ``reasoning_effort`` alias, so a pinned effort only reaches the wire
- if the client's other encodings are removed before the merge. Non-effort
- fields a carrier also holds (``output_config.format``,
- ``reasoning.summary``) are kept. An output ceiling has the same shape:
- ``max_tokens``, ``max_completion_tokens`` and ``max_output_tokens`` are
- one setting under three names, and a provider handed two of them either
- rejects the request or picks one by iteration order."""
- if not OUTPUT_TOKEN_CEILING_PARAMS.isdisjoint(tier_litellm_params):
- Router._stamp_client_output_ceiling(request_kwargs)
- for carrier in OUTPUT_TOKEN_CEILING_PARAMS:
- request_kwargs.pop(carrier, None)
- if "reasoning_effort" not in tier_litellm_params:
- return
- request_kwargs.pop("thinking", None)
- Router._pop_effort_from_nested_carrier(request_kwargs, "output_config")
- Router._pop_effort_from_nested_carrier(request_kwargs, "reasoning")
-
- async def _async_get_available_deployment_unresolved( # noqa: ANN202 # legacy selector result remains inferred
- self,
- model: str,
- request_kwargs: dict,
- messages: list[dict[str, str]] | None = None,
- input: str | list | None = None,
- specific_deployment: bool | None = False,
- ):
- """
- Async implementation of 'get_available_deployments'.
-
- Allows all cache calls to be made async => 10x perf impact (8rps -> 100 rps).
- """
- with phase_span(f"route {model}"):
- return await self._async_get_available_deployment(
- model=model,
- request_kwargs=request_kwargs,
- messages=messages,
- input=input,
- specific_deployment=specific_deployment,
- )
-
- async def _async_get_available_deployment(
- self,
- model: str,
- request_kwargs: dict,
- messages: list[dict[str, str]] | None,
- input: str | list | None,
- specific_deployment: bool | None,
- ):
- if (
- self._core_routing_strategy != "usage-based-routing-v2"
- and self._core_routing_strategy != "simple-shuffle"
- and self._core_routing_strategy != "cost-based-routing"
- and self._core_routing_strategy != "latency-based-routing"
- and self._core_routing_strategy != "least-busy"
- ): # prevent regressions for other routing strategies, that don't have async get available deployments implemented.
- return self._get_available_deployment_unresolved(
- model=model,
- messages=messages,
- input=input,
- specific_deployment=specific_deployment,
- request_kwargs=request_kwargs,
- )
- try:
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(request_kwargs)
-
- #########################################################
- # Execute Pre-Routing Hooks
- # this hook can modify the model, messages before the routing decision is made
- #########################################################
- responses_call: Final = input is not None and messages is None
- pre_routing_hook_response: Final = await self._core_async_pre_routing_hook(
- model=model,
- request_kwargs=request_kwargs,
- messages=messages,
- input=input,
- specific_deployment=specific_deployment,
- )
- if pre_routing_hook_response is not None:
- model = pre_routing_hook_response.model
- messages = pre_routing_hook_response.messages
- record_pre_routing_selection(request_kwargs, model)
- self._pin_tier_params_onto_request(
- model=model,
- tier_litellm_params=pre_routing_hook_response.litellm_params if pre_routing_hook_response else None,
- request_kwargs=request_kwargs,
- responses_call=responses_call,
- )
- #########################################################
-
- # Resolve the strategy and logger AFTER the pre-routing hook, since
- # the hook can replace `model` and routing-group lookup must key
- # off the final model name.
- strategy, strategy_selector = self._get_routing_context(model, request_kwargs)
- pick_attributes: Final = _deployment_pick_attributes(
- model, _ROUTING_KWARGS_ADAPTER.validate_python(request_kwargs)
- )
- routing_read_batch: Final = RoutingReadBatch.for_strategy(strategy, strategy_selector)
-
- with RoutingReadBatch.scoped(routing_read_batch):
- healthy_deployments: Final = await self._core_async_get_healthy_deployments(
- model=model,
- request_kwargs=request_kwargs,
- messages=messages,
- input=input,
- specific_deployment=specific_deployment,
- parent_otel_span=parent_otel_span,
- )
- if isinstance(healthy_deployments, dict):
- await self._async_override_selector_pre_call_check(
- strategy, strategy_selector, healthy_deployments, parent_otel_span
- )
- phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
- return healthy_deployments
-
- # When encrypted content affinity pins to a specific deployment,
- if request_kwargs.get("_encrypted_content_affinity_pinned") and len(healthy_deployments) == 1:
- await self._async_override_selector_pre_call_check(
- strategy, strategy_selector, healthy_deployments[0], parent_otel_span
- )
- phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
- return healthy_deployments[0]
-
- start_time: Final = time.time()
- if strategy == "simple-shuffle":
- shuffled: Final = simple_shuffle(
- resolve_model_alias=self._core_get_model_from_alias,
- healthy_deployments=healthy_deployments,
- model=model,
- request_kwargs=request_kwargs,
- )
- phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
- return shuffled
- with PrefetchedUsage.scoped(
- routing_read_batch.prefetched_usage if routing_read_batch is not None else None
- ):
- deployment: Final = await self._select_deployment_async(
- strategy=strategy,
- selector=strategy_selector,
- model=model,
- healthy_deployments=healthy_deployments,
- messages=messages,
- input=input,
- request_kwargs=request_kwargs,
- )
- if deployment is None:
- exception: Final = await async_raise_no_deployment_exception(
- ctx=self._core_ctx,
- model=model,
- parent_otel_span=parent_otel_span,
- )
- raise exception
- await self._async_override_selector_pre_call_check(
- strategy, strategy_selector, deployment, parent_otel_span
- )
- verbose_router_logger.info(
- "get_available_deployment for model: %s, Selected deployment: %s for model: %s",
- model,
- self.print_deployment(deployment),
- model,
- )
-
- end_time: Final = time.time()
- _duration: Final = end_time - start_time
- asyncio.create_task(
- self.service_logger_obj.async_service_success_hook(
- service=ServiceTypes.ROUTER,
- duration=_duration,
- call_type=".async_get_available_deployments",
- parent_otel_span=parent_otel_span,
- start_time=start_time,
- end_time=end_time,
- )
- )
-
- phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
- return deployment
- except Exception as e:
- mark_deployment_selection_failure(e)
- traceback_exception: Final = traceback.format_exc()
- # if router rejects call -> log to langfuse/otel/etc.
- if request_kwargs is not None:
- logging_obj: Final = request_kwargs.get("litellm_logging_obj", None)
-
- if logging_obj is not None:
- asyncio.create_task(
- logging_obj.dispatch_failure_handlers(
- exception=e,
- traceback_exception=traceback_exception,
- prefer_async_handlers=True,
- )
- )
- raise e
-
- _core_async_get_available_deployment = ( # pyright: ignore[reportUnknownVariableType] # legacy selector return
- async_resolve_pods_after(
- _async_get_available_deployment_unresolved # pyright: ignore[reportUnknownArgumentType] # legacy type
- )
- )
-
- async def async_get_available_deployment_for_pass_through(
- self,
- model: str,
- request_kwargs: dict,
- messages: list[dict[str, str]] | None = None,
- input: str | list | None = None,
- specific_deployment: bool | None = False,
- ):
- """
- Async version of get_available_deployment_for_pass_through
-
- Only returns deployments configured with use_in_pass_through=True
- """
- with phase_span(f"route {model}"):
- return await self._async_get_available_deployment_for_pass_through(
- model=model,
- request_kwargs=request_kwargs,
- messages=messages,
- input=input,
- specific_deployment=specific_deployment,
- )
-
- async def _async_get_available_deployment_for_pass_through(
- self,
- model: str,
- request_kwargs: dict,
- messages: list[dict[str, str]] | None,
- input: str | list | None,
- specific_deployment: bool | None,
- ):
- try:
- parent_otel_span: Final = get_parent_otel_span_from_kwargs(request_kwargs)
-
- # 1. Execute pre-routing hook
- responses_call: Final = input is not None and messages is None
- pre_routing_hook_response: Final = await self._core_async_pre_routing_hook(
- model=model,
- request_kwargs=request_kwargs,
- messages=messages,
- input=input,
- specific_deployment=specific_deployment,
- )
- if pre_routing_hook_response is not None:
- model = pre_routing_hook_response.model
- messages = pre_routing_hook_response.messages
- record_pre_routing_selection(request_kwargs, model)
- self._pin_tier_params_onto_request(
- model=model,
- tier_litellm_params=pre_routing_hook_response.litellm_params if pre_routing_hook_response else None,
- request_kwargs=request_kwargs,
- responses_call=responses_call,
- )
-
- # 2. Get healthy deployments
- healthy_deployments: Final = await self._core_async_get_healthy_deployments(
- model=model,
- request_kwargs=request_kwargs,
- messages=messages,
- input=input,
- specific_deployment=specific_deployment,
- parent_otel_span=parent_otel_span,
- )
-
- strategy, strategy_selector = self._get_routing_context(model, request_kwargs)
-
- # 3. If specific deployment returned, verify if it supports pass-through
- if isinstance(healthy_deployments, dict):
- if (healthy_deployments.get("model_info") or {}).get("blocked") is True:
- raise litellm.ServiceUnavailableError(
- message=f"Model '{model}' is currently paused and cannot accept requests.",
- model=model,
- llm_provider="",
- )
- litellm_params: Final = healthy_deployments.get("litellm_params", {})
- if litellm_params.get("use_in_pass_through"):
- await self._async_override_selector_pre_call_check(
- strategy, strategy_selector, healthy_deployments, parent_otel_span
- )
- return healthy_deployments
- else:
- raise litellm.BadRequestError(
- message=f"Deployment {healthy_deployments.get('model_info', {}).get('id')} does not support pass-through endpoint (use_in_pass_through=False)",
- model=model,
- llm_provider="",
- )
-
- # 4. Filter deployments that support pass-through
- pass_through_deployments = self._filter_pass_through_deployments(healthy_deployments=healthy_deployments)
-
- if len(pass_through_deployments) == 0:
- raise litellm.BadRequestError(
- message=f"Model {model} has no deployments configured with use_in_pass_through=True. Please add use_in_pass_through: true to the deployment configuration",
- model=model,
- llm_provider="",
- )
-
- # 5. Apply load balancing strategy
- start_time: Final = time.perf_counter()
- if strategy == "simple-shuffle":
- return simple_shuffle(
- resolve_model_alias=self._core_get_model_from_alias,
- healthy_deployments=pass_through_deployments,
- model=model,
- request_kwargs=request_kwargs,
- )
- deployment: Final = await self._select_deployment_async(
- strategy=strategy,
- selector=strategy_selector,
- model=model,
- healthy_deployments=pass_through_deployments,
- messages=messages,
- input=input,
- request_kwargs=request_kwargs,
- )
-
- if deployment is None:
- exception: Final = await async_raise_no_deployment_exception(
- ctx=self._core_ctx,
- model=model,
- parent_otel_span=parent_otel_span,
- )
- raise exception
- await self._async_override_selector_pre_call_check(
- strategy, strategy_selector, deployment, parent_otel_span
- )
-
- verbose_router_logger.info(
- "async_get_available_deployment_for_pass_through model: %s, selected deployment: %s",
- model,
- self.print_deployment(deployment),
- )
-
- end_time: Final = time.perf_counter()
- _duration: Final = end_time - start_time
- asyncio.create_task(
- self.service_logger_obj.async_service_success_hook(
- service=ServiceTypes.ROUTER,
- duration=_duration,
- call_type=".async_get_available_deployments",
- parent_otel_span=parent_otel_span,
- start_time=start_time,
- end_time=end_time,
- )
- )
-
- return deployment
- except Exception as e:
- mark_deployment_selection_failure(e)
- traceback_exception: Final = traceback.format_exc()
- if request_kwargs is not None:
- logging_obj: Final = request_kwargs.get("litellm_logging_obj", None)
- if logging_obj is not None:
- asyncio.create_task(
- logging_obj.dispatch_failure_handlers(
- exception=e,
- traceback_exception=traceback_exception,
- prefer_async_handlers=True,
- )
- )
- raise e
-
- async def _run_routing_plugins(
- self,
- model: str,
- request_kwargs: dict,
- messages: list[dict[str, object]] | None,
- ) -> RoutingContext:
- """
- Build a RoutingContext for `model`, run it through `self._core_routing_plugins`
- in order, then stash the narrowed candidate list and accumulated signals
- onto `request_kwargs["metadata"]` so `_filter_by_routing_plugin_candidates`
- (called later, during healthy-deployment filtering) can consume them.
- """
- from litellm.litellm_core_utils.prompt_templates.factory import (
- resolve_structured_messages,
- )
-
- candidate_models: Final = list(self._core_resolved_litellm_models(model))
-
- metadata_key: Final = self._get_metadata_variable_name_from_kwargs(request_kwargs)
- metadata: Final = request_kwargs.setdefault(metadata_key, {})
-
- context = RoutingContext(
- raw_messages=messages or [],
- structured_messages=resolve_structured_messages(messages=messages, request_kwargs=request_kwargs) or [],
- candidate_models=candidate_models,
- metadata=metadata,
- )
-
- for plugin in self._core_routing_plugins:
- context = await plugin.run(context)
-
- metadata["routing_plugin_signals"] = context.signals
- if len(context.candidate_models) < len(candidate_models):
- metadata["_routing_plugin_candidate_models"] = context.candidate_models
-
- return context
-
- def _filter_by_routing_plugin_candidates(
- self,
- healthy_deployments: list[dict] | dict,
- request_kwargs: dict,
- ) -> list[dict] | dict:
- """
- Narrow `healthy_deployments` to whatever `self._core_routing_plugins` left in
- `context.candidate_models`. Raises rather than silently falling back to
- the unfiltered pool -- a plugin narrowing to nothing is a policy decision
- (e.g. no model this tenant's budget allows), not something to bypass.
- """
- if not self._core_routing_plugins or not isinstance(healthy_deployments, list):
- return healthy_deployments
-
- metadata_key: Final = self._get_metadata_variable_name_from_kwargs(request_kwargs)
- candidate_models: Final = (request_kwargs.get(metadata_key) or {}).get("_routing_plugin_candidate_models")
- # `is None` (not falsy-check): a plugin narrowing to an empty list must
- # still hit the "no deployments left" raise below, not be treated the
- # same as "no plugin ever set this key".
- if candidate_models is None:
- return healthy_deployments
-
- candidate_set: Final = set(candidate_models)
- filtered: Final = [d for d in healthy_deployments if d.get("litellm_params", {}).get("model") in candidate_set]
-
- if not filtered:
- raise ValueError(f"No deployments left after routing-plugin filtering. candidate_models={candidate_models}")
-
- return filtered
-
- def _select_pre_routing_strategy(
- self, model: str, request_kwargs: Mapping[str, object]
- ) -> "TaggedPreRoutingStrategy[PreRoutingStrategy] | None":
- """
- Resolve the pre-routing strategy for `model`, disambiguating deployments
- that share a `model_name` by matching the request's tags against each
- registered strategy's tags before falling back to the first registered.
- Returns the tagged registry entry so the caller can tell whether the
- request's tags were what selected it, and can locate the marker
- deployment the strategy was registered from via its (model_name, tags)
- pair.
-
- The registries are keyed by the marker deployment's own `model_name`, which
- for a team-scoped router is the internal `model_name_{team}_{uuid}` while
- the caller sends the team's public name. So the names looked up are the
- `model_name`s of whatever deployments this caller's request resolves `model`
- to, and `model` itself when it resolves to none.
-
- With tag filtering enabled, router-wide or by the request's
- enable_tag_filtering (which the proxy sets from key/team
- router_settings), strategies that all carry real tags matching none of
- the request's do not capture it when the name also has plain
- deployments: returning None hands the request to ordinary tag-aware
- deployment selection.
- """
- registries: Final = (
- self._core_auto_routers,
- self._core_complexity_routers,
- self._core_adaptive_routers,
- self._core_quality_routers,
- )
- if not any(registries):
- return None
- deployments: Final = self._core_deployments_for_request(model, request_kwargs)
- registered_names: Final = tuple(dict.fromkeys(str(d["model_name"]) for d in deployments)) or (model,)
- candidates: Final = tuple(
- tagged for registry in registries for name in registered_names for tagged in registry.get(name, [])
- )
- if not candidates:
- return None
-
- request_tags: Final = get_routing_tags_from_request_kwargs(request_kwargs)
- if request_tags:
- for tagged in candidates:
- if tagged.tags and is_valid_deployment_tag(
- list(tagged.tags), request_tags, self._core_tag_filtering_match_any
- ):
- return tagged
- for tagged in candidates:
- if "default" in tagged.tags:
- return tagged
- request_scoped_filtering: Final = request_kwargs.get("enable_tag_filtering") is True
- if (
- (self._core_enable_tag_filtering or request_scoped_filtering)
- and all(tagged.tags for tagged in candidates)
- and any(not self._is_strategy_marker_deployment(d) for d in deployments)
- ):
- return None
- return candidates[0]
-
- @staticmethod
- def _request_header(request_kwargs: Mapping[str, object], header_name: str) -> str | None:
- proxy_server_request: Final = request_kwargs.get("proxy_server_request")
- if not isinstance(proxy_server_request, Mapping):
- return None
- headers: Final = proxy_server_request.get("headers")
- if not isinstance(headers, Mapping):
- return None
- return next(
- (
- value
- for key, value in headers.items()
- if isinstance(key, str) and key.lower() == header_name and isinstance(value, str)
- ),
- None,
- )
-
- def _claude_code_session_router_cache_key(self, request_kwargs: Mapping[str, object]) -> str | None:
- session_id: Final = self._request_header(request_kwargs, "x-claude-code-session-id")
- if session_id is None or _CLAUDE_CODE_SESSION_ID_RE.fullmatch(session_id) is None:
- return None
- metadata_name: Final = "litellm_metadata" if "litellm_metadata" in request_kwargs else "metadata"
- metadata: Final = request_kwargs.get(metadata_name)
- if not isinstance(metadata, Mapping):
- return None
- caller_scope: Final = metadata.get("user_api_key_hash")
- if not isinstance(caller_scope, str) or not caller_scope:
- return None
- return f"claude_code_session_router:v1:{caller_scope}:{session_id}"
-
- @with_service_target(CLAUDE_CODE_SESSION_ROUTER_BINDING_TARGET)
- async def _delete_claude_code_session_router_binding(self, cache_key: str) -> None:
- try:
- await self._claude_code_session_router_cache.async_delete_cache(key=cache_key)
- except Exception as e: # noqa: BLE001 # cache cleanup must not fail an otherwise routable request
- verbose_router_logger.warning(
- "Failed to delete Claude Code session router binding; the binding may remain until its TTL expires: %s",
- e,
- )
-
- @with_service_target(CLAUDE_CODE_SESSION_ROUTER_BINDING_TARGET)
- async def _get_claude_code_session_router_binding(self, cache_key: str) -> object:
- session_cache: Final = self._claude_code_session_router_cache
- try:
- if session_cache.redis_cache is None:
- return await session_cache.async_get_cache(key=cache_key)
- return await session_cache.redis_cache.async_get_cache(key=cache_key)
- except Exception as e: # noqa: BLE001 # an optional binding must not make routing depend on Redis
- log_redis_failure(
- verbose_router_logger,
- logging.WARNING,
- "Failed to read Claude Code session router binding; using the requested model",
- e,
- )
- return None
-
- @with_service_target(CLAUDE_CODE_SESSION_ROUTER_BINDING_TARGET)
- async def _resolve_claude_code_session_router(
- self,
- model: str,
- registered_model_name: str,
- request_kwargs: Mapping[str, object],
- ) -> str:
- if is_native_compaction_call():
- return registered_model_name
- if not any(
- (
- self._core_auto_routers,
- self._core_complexity_routers,
- self._core_adaptive_routers,
- self._core_quality_routers,
- )
- ):
- return registered_model_name
- cache_key: Final = self._claude_code_session_router_cache_key(request_kwargs)
- if cache_key is None or not isinstance(request_kwargs, dict):
- return registered_model_name
- if request_kwargs.get("fallback_depth") not in (None, 0):
- return registered_model_name
-
- agent_id: Final = self._request_header(request_kwargs, "x-claude-code-agent-id")
- if agent_id is not None:
- bound_model: Final = await self._get_claude_code_session_router_binding(cache_key)
- if not isinstance(bound_model, str):
- return registered_model_name
- bound_registered_model: Final = self._core_get_model_from_alias(model=bound_model) or bound_model
- if self._select_pre_routing_strategy(bound_registered_model, request_kwargs) is None:
- await self._delete_claude_code_session_router_binding(cache_key)
- return registered_model_name
- await self._claude_code_session_router_cache.async_set_cache(
- key=cache_key,
- value=bound_model,
- ttl=_CLAUDE_CODE_SESSION_ROUTER_TTL_SECONDS,
- )
- self._stamp_or_clear_metadata_key(request_kwargs, "model_group", bound_model)
- return bound_registered_model
-
- if self._select_pre_routing_strategy(registered_model_name, request_kwargs) is None:
- return registered_model_name
- await self._claude_code_session_router_cache.async_set_cache(
- key=cache_key,
- value=model,
- ttl=_CLAUDE_CODE_SESSION_ROUTER_TTL_SECONDS,
- )
- return registered_model_name
-
- async def _core_async_pre_routing_hook(
- self,
- model: str,
- request_kwargs: dict[str, object],
- messages: list[dict[str, Any]] | None = None,
- input: str | list | None = None,
- specific_deployment: bool | None = False,
- ) -> PreRoutingHookResponse | None:
- """
- This hook is called before the routing decision is made.
-
- Used for the litellm auto-router to modify the request before the routing decision is made.
-
- `model` is whatever the caller asked for, which may be a `model_group_alias` key or a team's
- public model name, while the strategy registries and the marker deployment are keyed by the
- marker's own `model_name`, so every lookup below resolves the alias first and the team name
- through the deployment path. Only the lookups: the caller-facing name stays the alias, since
- spend metadata is stamped before routing and the response carries the tier group the
- strategy picked.
- """
- self._restore_client_output_ceiling(request_kwargs)
- self._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None)
- requested_registered_model_name: Final = self._core_get_model_from_alias(model=model) or model
- registered_model_name: Final = await self._resolve_claude_code_session_router(
- model=model,
- registered_model_name=requested_registered_model_name,
- request_kwargs=request_kwargs,
- )
-
- #########################################################
- # Run the routing-plugin pipeline, if any plugins are configured.
- # Plugins narrow the candidate deployment pool (consumed later by
- # `_filter_by_routing_plugin_candidates`) and may attach signals for
- # downstream strategies (auto-router, complexity-router, ...) to read.
- #########################################################
- if self._core_routing_plugins:
- await self._run_routing_plugins(
- model=registered_model_name, request_kwargs=request_kwargs, messages=messages
- )
-
- selected_strategy: Final = self._select_pre_routing_strategy(
- model=registered_model_name, request_kwargs=request_kwargs
- )
- if selected_strategy is None:
- await arm_compaction(request_kwargs, None)
- self._stamp_or_clear_metadata_key(
- request_kwargs=request_kwargs, key=SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY, value=None
- )
- self._stamp_or_clear_metadata_key(
- request_kwargs=request_kwargs, key=CONSUMED_REQUEST_TAGS_METADATA_KEY, value=None
- )
- return None
-
- from litellm.router_strategy.complexity_router.complexity_router import ComplexityRouter
-
- marker: Final = self._selected_strategy_marker_deployment(
- model=registered_model_name, strategy_tags=selected_strategy.tags, request_kwargs=request_kwargs
- )
- definition: Final = selected_strategy.deployment
- config_id: Final = definition.model_info.id if definition else None
- updated_at: Final = definition.model_info.updated_at if definition else None
- router_kind: Final = classify_strategy_router_model(definition.litellm_params.model) if definition else None
- provenance: Final[StandardLoggingRoutingDecision] = {
- "router_model_name": definition.model_name if definition else registered_model_name,
- **({"router_type": router_kind} if router_kind is not None else {}),
- **({"router_config_id": config_id} if config_id is not None else {}),
- **({"router_config_updated_at": updated_at.isoformat()} if updated_at is not None else {}),
- **(
- {"router_config_fingerprint": selected_strategy.definition_fingerprint}
- if selected_strategy.definition_fingerprint is not None
- else {}
- ),
- }
- phase_attributes(routing_decision_attributes(provenance))
-
- reject_recursive_compactor(registered_model_name)
- await arm_compaction(
- request_kwargs,
- selected_strategy.strategy.config.context_compaction
- if isinstance(selected_strategy.strategy, ComplexityRouter)
- else None,
- tuple(
- dict.fromkeys(
- member
- for pool in selected_strategy.strategy.config.tiers.values()
- for member in ((pool,) if isinstance(pool, str) else pool)
- )
- )
- if isinstance(selected_strategy.strategy, ComplexityRouter)
- else (),
- parent_model=model,
- ctx=self._core_ctx,
- allow_escalation=isinstance(selected_strategy.strategy, ComplexityRouter)
- and selected_strategy.strategy.config.enable_context_window_escalation,
- messages=messages,
- )
-
- await router_host().authorize_strategy_inference(
- deployment=marker,
- request_kwargs=request_kwargs,
- llm_router=self,
- )
-
- # Same tag-aware lookup the proxy's pre-call arming used, so an alias with
- # several tag-scoped markers cannot suppress under one and route under another.
- compression_policy: Final = router_host().compression_policy(
- llm_router=self,
- model_alias=registered_model_name,
- request_kwargs=request_kwargs,
- request_tags=get_routing_tags_from_request_kwargs(request_kwargs),
- )
- # Shared compression already ran in the pre-call hook, so reuse it rather than
- # compressing twice. Conditional on arming having actually happened: only the
- # proxy arms, and on the SDK path the shortcut would skip both hops entirely.
- needs_independent_routing_compression: Final = compression_policy is not None and not (
- compression_policy.is_same
- and compression_policy.model is not None
- and router_host().model_hop_compression_armed()
- )
- routing_messages: Final = (
- await router_host().messages_for_routing(
- policy=compression_policy, messages=messages, request_kwargs=request_kwargs
- )
- if needs_independent_routing_compression
- else None
- )
-
- routed: Final = await selected_strategy.strategy.async_pre_routing_hook(
- model=registered_model_name,
- request_kwargs=request_kwargs,
- messages=routing_messages if routing_messages is not None else messages,
- input=input,
- specific_deployment=specific_deployment,
- )
- traced_routed: Final = (
- routed.model_copy(update={"routing_decision": {**provenance, **routed.routing_decision}})
- if routed is not None and routed.routing_decision is not None
- else routed
- )
- # Routing-only compression must not leak into the response: the model call and
- # deployment-context filtering key off this field. Compared by value, since
- # pydantic rebuilds the list rather than keeping the object passed in.
- pre_routing_hook_response: Final = (
- traced_routed.model_copy(update={"messages": messages})
- if traced_routed is not None and routing_messages is not None and traced_routed.messages == routing_messages
- else traced_routed
- )
- self._record_routing_decision(
- request_kwargs=request_kwargs,
- routing_decision=(pre_routing_hook_response.routing_decision if pre_routing_hook_response else None),
- )
- self._stamp_or_clear_metadata_key(
- request_kwargs=request_kwargs,
- key=SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
- value=(pre_routing_hook_response.session_affinity_ttl_seconds if pre_routing_hook_response else None),
- )
- self._stamp_or_clear_metadata_key(
- request_kwargs=request_kwargs,
- key=CONSUMED_REQUEST_TAGS_METADATA_KEY,
- value=self._consumed_request_tags_stamp(
- selected_strategy=selected_strategy,
- pre_routing_hook_response=pre_routing_hook_response,
- request_tags=get_routing_tags_from_request_kwargs(request_kwargs),
- ),
- )
-
- # `model` (the alias, e.g. "smart-router") is never the deployment actually
- # called - apply the router marker's own litellm_params to the request,
- # since the tier/route deployment the hook selected won't have them. The
- # marker entry is looked up by its `auto_router/` model prefix and the
- # selected strategy's tags, never by list position: plain deployments may
- # share the alias `model_name` and must not leak their params (`api_base`,
- # `api_key`, ...) onto the routed call. Router-only fields (tpm, rpm,
- # weight, complexity_router_config, ...) are excluded from the actual
- # outbound LLM call downstream by litellm.types.utils.all_litellm_params,
- # not here. Custom pricing fields ARE call params, so they must be
- # excluded here: they price the alias, not the deployment the hook
- # selected, and forwarding them re-registers the routed deployment at
- # the alias's price (an explicit 0 makes every alias request bill $0).
- # Forwarded params only fill gaps: the keys inserted here ride along on the
- # request (top level, so sibling requests sharing a `metadata` dict never see
- # them) until `_update_kwargs_with_deployment` drops any the selected
- # deployment sets itself (its own `aws_region_name` beats the marker's).
- # Per-tier `litellm_params` on the hook response are deliberate overrides
- # the caller applies on top, so those keys and equivalent output-limit
- # names are never tracked as alias defaults here.
- marker_params: Final = (
- self._forwardable_alias_marker_params(
- model=registered_model_name, strategy_tags=selected_strategy.tags, request_kwargs=request_kwargs
- )
- if pre_routing_hook_response is not None
- else ()
- )
- tier_param_keys: Final = (
- tuple(pre_routing_hook_response.litellm_params or ()) if pre_routing_hook_response is not None else ()
- )
- overridden_marker_keys: Final = frozenset(tier_param_keys) | (
- OUTPUT_TOKEN_CEILING_PARAMS if not OUTPUT_TOKEN_CEILING_PARAMS.isdisjoint(tier_param_keys) else frozenset()
- )
- newly_forwarded: Final = tuple(
- (key, value)
- for key, value in marker_params
- if key not in request_kwargs and key not in overridden_marker_keys
- )
- if pre_routing_hook_response is not None:
- self._stamp_client_output_ceiling(request_kwargs)
- request_kwargs.pop(_ALIAS_MARKER_FORWARDED_PARAMS_KWARG, None)
- request_kwargs.update(newly_forwarded)
- if newly_forwarded:
- request_kwargs.update(((_ALIAS_MARKER_FORWARDED_PARAMS_KWARG, tuple(key for key, _ in newly_forwarded)),))
-
- return pre_routing_hook_response
-
- def _selected_strategy_marker_deployment(
- self, model: str, strategy_tags: tuple[str, ...], request_kwargs: Mapping[str, object]
- ) -> DeploymentTypedDict | None:
- markers: Final = tuple(
- deployment
- for deployment in self._core_deployments_for_request(model, request_kwargs)
- if "model" in deployment["litellm_params"]
- and str(deployment["litellm_params"]["model"]).startswith(AUTO_ROUTER_MODEL_PREFIX)
- )
- tag_matched: Final = tuple(
- deployment
- for deployment in markers
- if (tuple(deployment["litellm_params"]["tags"] or ()) if "tags" in deployment["litellm_params"] else ())
- == strategy_tags
- )
- return tag_matched[0] if tag_matched else (markers[0] if markers else None)
-
- def _forwardable_alias_marker_params(
- self, model: str, strategy_tags: tuple[str, ...], request_kwargs: Mapping[str, object]
- ) -> tuple[tuple[str, object], ...]:
- marker: Final = self._selected_strategy_marker_deployment(
- model=model, strategy_tags=strategy_tags, request_kwargs=request_kwargs
- )
- if marker is None:
- return ()
- return tuple(
- (key, value)
- for key, value in marker["litellm_params"].items()
- if key not in _ALIAS_PARAMS_NEVER_FORWARDED
- and key not in CustomPricingLiteLLMParams.model_fields
- and value is not None
- )
-
- @staticmethod
- def _forwarded_alias_marker_keys_the_deployment_sets(
- deployment: Mapping[str, object], forwarded_keys: object
- ) -> tuple[str, ...]:
- deployment_litellm_params: Final = deployment.get("litellm_params")
- if not isinstance(deployment_litellm_params, Mapping) or not isinstance(forwarded_keys, tuple):
- return ()
- return tuple(
- key
- for key in forwarded_keys
- if isinstance(key, str) and Router._deployment_sets_litellm_param(deployment_litellm_params, key)
- )
-
- @staticmethod
- def _deployment_sets_litellm_param(deployment_litellm_params: Mapping[str, object], key: str) -> bool:
- value: Final = deployment_litellm_params.get(key)
- if value is None:
- return False
- field: Final = LiteLLM_Params.model_fields.get(key)
- return field is None or value != field.default
-
- def _consumed_request_tags_stamp(
- self,
- selected_strategy: "TaggedPreRoutingStrategy[PreRoutingStrategy]",
- pre_routing_hook_response: PreRoutingHookResponse | None,
- request_tags: Sequence[str],
- ) -> ConsumedRequestTagsStamp | None:
- """Record which tags picked the router and which model group it rewrote to, or None.
-
- A request whose tags matched the selected strategy's tags has already spent those
- tags on picking the router; re-applying them to the routed tier's model group would
- empty the pool unless every tier deployment repeats the marker's tag. Only the
- strategy's own tags are spent: the request's other tags keep constraining
- deployment selection inside the routed group, and key/team policy tags are
- untouched because tag filtering separately re-applies whatever
- `metadata.inherited_tags` carries for the stamped group.
- """
- if pre_routing_hook_response is None or not selected_strategy.tags or not request_tags:
- return None
- if not is_valid_deployment_tag(selected_strategy.tags, request_tags, self._core_tag_filtering_match_any):
- return None
- return ConsumedRequestTagsStamp(model_group=pre_routing_hook_response.model, tags=selected_strategy.tags)
-
- @staticmethod
- def _record_routing_decision(
- request_kwargs: dict,
- routing_decision: StandardLoggingRoutingDecision | None,
- ) -> None:
- """Make the request's metadata describe THIS routing attempt, and only this one.
-
- Fallbacks re-enter the hook with the same `request_kwargs`, so an attempt that
- picks a plain model group after an auto-router group failed must clear the
- earlier decision; leaving it would attribute the first router's tier and cause
- to the deployment that actually served the request. Every attempt therefore
- writes or clears, never just writes.
- """
- from litellm.router_strategy.auto_router.baseline_request import capture_baseline_parameters
- from litellm.types.router import BaselineRouteStamp
-
- phase_attributes(routing_decision_attributes(routing_decision))
- baseline_model: Final = routing_decision.get("savings_baseline_model") if routing_decision else None
- baseline_id: Final = routing_decision.get("savings_baseline_deployment_id") if routing_decision else None
- router_name: Final = routing_decision.get("router_model_name") if routing_decision else None
- caller_parameters: Final = (
- capture_baseline_parameters(request_kwargs) if router_name and baseline_model else None
- )
- Router._stamp_or_clear_metadata_key(
- request_kwargs=request_kwargs,
- key="_autorouter_baseline_route",
- value=(
- BaselineRouteStamp(router_name, baseline_model, baseline_id, caller_parameters)
- if router_name and baseline_model and baseline_id
- else None
- ),
- )
- Router._stamp_or_clear_metadata_key(
- request_kwargs=request_kwargs,
- key="routing_decision",
- value=(
- None
- if routing_decision is None
- else Router._redact_prompt_text_if_needed(
- request_kwargs=request_kwargs, routing_decision=routing_decision
- )
- ),
- )
-
- @staticmethod
- def _stamp_or_clear_metadata_key(request_kwargs: dict, key: str, value: object | None) -> None:
- """Write a proxy-internal metadata key for THIS routing attempt, or clear it.
-
- Fallbacks and retries re-enter the pre-routing hook with the same
- `request_kwargs`, so every attempt must write or clear, never just write;
- a value left behind by an earlier attempt would be attributed to this one.
- `get_or_create_metadata_bucket` is the single owner of "which dict holds
- proxy-internal metadata": it picks `litellm_metadata` when present (so the
- value never lands in the `metadata` dict that routes like /v1/messages
- forward to the provider) and replaces a non-dict value rather than silently
- skipping the write. Clearing pops from BOTH buckets so a request whose
- bucket resolution changed between attempts cannot resurrect a stale value.
- """
- if value is None:
- for bucket in (request_kwargs.get("metadata"), request_kwargs.get("litellm_metadata")):
- if isinstance(bucket, dict):
- bucket.pop(key, None)
- return
-
- _, metadata_bucket = get_or_create_metadata_bucket(request_kwargs)
- metadata_bucket[key] = value
-
- @staticmethod
- def _redact_prompt_text_if_needed(
- request_kwargs: Mapping[str, object],
- routing_decision: StandardLoggingRoutingDecision,
- ) -> StandardLoggingRoutingDecision:
- """Drop verbatim prompt text from the record when message logging is redacted.
-
- An operator who turns message logging off has said prompt content must not reach
- the logs, so the fields that quote the prompt (the matched keywords, and the
- signals that name them) are omitted. Derived values are kept, because a tier, a
- cause, a score or an escalation flag aggregates the prompt rather than
- reproducing any of it, and dropping them would leave the row unexplainable for
- no privacy gain. Applied here rather than in each strategy so a strategy added
- later cannot bypass it.
- """
- from litellm.litellm_core_utils.redact_messages import (
- should_redact_message_logging,
- )
-
- if not should_redact_message_logging(
- {
- "litellm_params": request_kwargs,
- "standard_callback_dynamic_params": request_kwargs.get("standard_callback_dynamic_params"),
- }
- ):
- return routing_decision
- kept: Final = {
- field: value
- for field, value in routing_decision.items()
- if field not in PROMPT_QUOTING_ROUTING_DECISION_FIELDS
- }
- return cast(StandardLoggingRoutingDecision, kept) # cast-ok: dropping optional keys preserves the type
-
- def _get_available_deployment_unresolved( # noqa: ANN202 # legacy selector result remains inferred
- self,
- model: str,
- messages: list[dict[str, str]] | None = None,
- input: str | list | None = None,
- specific_deployment: bool | None = False,
- request_kwargs: dict | None = None,
- ):
- """
- Returns the deployment based on routing strategy
- """
- if self._core_routing_plugins:
- raise ValueError(
- "Router(plugins=[...]) is configured, but this call resolved to the synchronous "
- "deployment-selection path, which never runs the routing-plugin pipeline. This "
- "happens for sync Router methods (e.g. Router.completion()) and for async calls "
- "with a routing_strategy that has no async-native selector (e.g. legacy "
- "'usage-based-routing', v1). Silently skipping "
- "configured plugins would let a policy plugin (e.g. a deny-all rule) be bypassed. "
- "Use an async Router method with a supported routing_strategy (simple-shuffle, "
- "usage-based-routing-v2, cost-based-routing, latency-based-routing, least-busy), "
- "or remove `plugins` from the Router config."
- )
- # users need to explicitly call a specific deployment, by setting `specific_deployment = True` as completion()/embedding() kwarg
- # When this was no explicit we had several issues with fallbacks timing out
-
- model, healthy_deployments = self._common_checks_available_deployment(
- model=model,
- messages=messages,
- input=input,
- specific_deployment=specific_deployment,
- request_kwargs=request_kwargs,
- )
- healthy_deployments = ( # pyright: ignore[reportUnknownVariableType] # rebind-ok: route filter narrows
- self._without_deployments_that_cannot_serve_route(
- healthy_deployments, # pyright: ignore[reportUnknownArgumentType] # legacy selector type
- model,
- )
- )
- strategy, strategy_selector = self._get_routing_context(model, request_kwargs)
- routing_kwargs: Final = _ROUTING_KWARGS_ADAPTER.validate_python(request_kwargs)
- pick_attributes: Final = _deployment_pick_attributes(model, routing_kwargs)
-
- if isinstance(healthy_deployments, dict):
- if (healthy_deployments.get("model_info") or {}).get("blocked") is True:
- raise litellm.ServiceUnavailableError(
- message=f"Model '{model}' is currently paused and cannot accept requests.",
- model=model,
- llm_provider="",
- )
- self._override_selector_pre_call_check(strategy, strategy_selector, healthy_deployments)
- phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
- return healthy_deployments
-
- parent_otel_span: Final[Span | None] = get_parent_otel_span_from_kwargs(request_kwargs)
- request_deployments: Final = cast( # cast-ok: router deployments are plain dicts
- Sequence[DeploymentTypedDict], healthy_deployments
- )
-
- # Health-check-based filtering (before cooldown)
- healthy_deployments = self._filter_health_check_unhealthy_deployments(
- healthy_deployments=healthy_deployments,
- parent_otel_span=parent_otel_span,
- )
-
- cooldown_deployments: Final = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
- _pre_cooldown_deployments: Final = healthy_deployments
- healthy_deployments = self._filter_cooldown_deployments(
- healthy_deployments=healthy_deployments,
- cooldown_deployments=cooldown_deployments,
- )
- if (
- not healthy_deployments
- and self._core_enable_health_check_routing
- and self._core_allowed_fails_policy is not None
- ):
- verbose_router_logger.warning(
- "All deployments in cooldown via health-check routing, bypassing cooldown filter"
- )
- healthy_deployments = _pre_cooldown_deployments
-
- healthy_deployments = continuation_deployments(
- self._filter_blocked_deployments(healthy_deployments), routing_kwargs
- )
-
- # filter pre-call checks
- if self._core_enable_pre_call_checks and (messages is not None or input is not None):
- healthy_deployments = self._pre_call_checks(
- model=model,
- healthy_deployments=healthy_deployments,
- messages=messages,
- input=input,
- request_kwargs=request_kwargs,
- request_deployments=request_deployments,
- )
-
- ## ORDER FILTERING ## -> if user set 'order' in deployments, return deployments with lowest order (e.g. order=1 > order=2)
- _target_order: Final = (request_kwargs or {}).pop("_target_order", None)
- batch_candidates: Final = self._without_decisions_deployments_refusing_media(
- self._without_declared_text_only_deployments(
- cast(list[dict[str, object]], healthy_deployments), # cast-ok: router deployments are plain dicts
- model,
- messages,
- cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict
- ),
- model,
- cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict
- )
- embedding_batch_admitting: Final = deployments_admitting_embedding_batch(
- batch_candidates,
- _OPAQUE_VALUE_ADAPTER.validate_python(input),
- routing_kwargs,
- )
- embedding_batch_routed: Final = (
- embedding_batch_admitting
- if embedding_batch_admitting is not None
- else deployments_when_none_admits_embedding_batch(
- batch_candidates,
- cast(list[dict[str, object]], _pre_cooldown_deployments), # cast-ok: router deployments are plain dicts
- self._deployment_ids(
- self._region_routable_deployments(
- request_deployments,
- cast(Mapping[str, object] | None, request_kwargs), # cast-ok: str-keyed request kwargs
- True,
- )
- )
- & frozenset(cooldown_deployments),
- _OPAQUE_VALUE_ADAPTER.validate_python(input),
- routing_kwargs,
- )
- )
- healthy_deployments = litellm.utils.get_order_filtered_deployments(
- list(
- self._narrow_to_video_deployment(
- without_deployments_that_cannot_batch(
- embedding_batch_routed,
- caller_deployments=lambda: deployments_the_caller_may_use(batch_candidates, routing_kwargs),
- )
- )
- ),
- target_order=_target_order,
- )
-
- ## WEIGHTED FAILOVER EXCLUSION ## -> drop deployments already tried in
- ## this request via weighted-failover. See async counterpart in
- ## async_get_healthy_deployments for details.
- _excluded_deployment_ids: Final = (request_kwargs or {}).pop("_excluded_deployment_ids", None)
- healthy_deployments = litellm.utils.get_excluded_filtered_deployments(
- healthy_deployments,
- excluded_deployment_ids=_excluded_deployment_ids,
- )
-
- ## RETRY SKIP ## -> see async counterpart in async_get_healthy_deployments.
- _retry_skipped_deployment_ids: Final = as_retry_skipped_deployment_ids(
- request_kwargs.pop("_retry_skipped_deployment_ids", None) if request_kwargs else None
- )
- healthy_deployments = (
- litellm.utils.get_excluded_filtered_deployments(
- healthy_deployments, excluded_deployment_ids=_retry_skipped_deployment_ids
- )
- or healthy_deployments
- )
-
- if len(healthy_deployments) == 0:
- model_ids = self._core_get_model_ids(model_name=model)
- _cooldown_time = self._core_cooldown_cache.get_min_cooldown(
- model_ids=model_ids, parent_otel_span=parent_otel_span
- )
- _cooldown_list = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
- raise RouterRateLimitError(
- model=model,
- cooldown_time=_cooldown_time,
- enable_pre_call_checks=self._core_enable_pre_call_checks,
- cooldown_list=_cooldown_list,
- model_ids=model_ids,
- cooldown_deployments=get_cooldown_deployment_info(
- self._core_ctx,
- self._deployment_ids(
- self._region_routable_deployments(
- request_deployments,
- cast(Mapping[str, object] | None, request_kwargs), # cast-ok: str-keyed request kwargs
- messages is not None or input is not None,
- )
- ),
- _cooldown_list,
- ),
- )
-
- if strategy == "simple-shuffle":
- # if users pass rpm or tpm, we do a random weighted pick - based on rpm/tpm
- ############## Check 'weight' param set for weighted pick #################
- shuffled: Final = simple_shuffle(
- resolve_model_alias=self._core_get_model_from_alias,
- healthy_deployments=healthy_deployments,
- model=model,
- request_kwargs=request_kwargs,
- )
- phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
- return shuffled
- deployment: Final = self._select_deployment_sync(
- strategy=strategy,
- selector=strategy_selector,
- model=model,
- healthy_deployments=healthy_deployments,
- messages=messages,
- input=input,
- request_kwargs=request_kwargs,
- )
-
- if deployment is None:
- verbose_router_logger.info("get_available_deployment for model: %s, No deployment available", model)
- model_ids = self._core_get_model_ids(model_name=model)
- _cooldown_time = self._core_cooldown_cache.get_min_cooldown(
- model_ids=model_ids, parent_otel_span=parent_otel_span
- )
- _cooldown_list = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
- raise RouterRateLimitError(
- model=model,
- cooldown_time=_cooldown_time,
- enable_pre_call_checks=self._core_enable_pre_call_checks,
- cooldown_list=_cooldown_list,
- model_ids=model_ids,
- )
- self._override_selector_pre_call_check(strategy, strategy_selector, deployment)
- verbose_router_logger.info(
- "get_available_deployment for model: %s, Selected deployment: %s for model: %s",
- model,
- self.print_deployment(deployment),
- model,
- )
- phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
- return deployment
-
- get_available_deployment = ( # pyright: ignore[reportUnknownVariableType] # legacy selector return
- resolve_pods_after(
- _get_available_deployment_unresolved # pyright: ignore[reportUnknownArgumentType] # legacy annotations
- )
- )
-
- def _core_get_available_deployment_for_pass_through( # noqa: ANN202 # unannotated before the rename too; its inferred return mixes untyped deployment dicts
- self,
- model: str,
- messages: list[dict[str, str]] | None = None,
- input: str | list | None = None,
- specific_deployment: bool | None = False,
- request_kwargs: dict | None = None,
- ):
- """
- Returns deployments available for pass-through endpoints (based on load balancing strategy)
-
- Similar to get_available_deployment, but only returns deployments with use_in_pass_through=True
-
- Args:
- model: Model name
- messages: Optional list of messages
- input: Optional input data
- specific_deployment: Whether to find a specific deployment
- request_kwargs: Optional request parameters
-
- Returns:
- Dict: Selected deployment configuration
-
- Raises:
- BadRequestError: If no deployment is configured with use_in_pass_through=True
- RouterRateLimitError: If no pass-through deployments are available
- """
- # 1. Perform common checks to get healthy deployments list
- model, healthy_deployments = self._common_checks_available_deployment(
- model=model,
- messages=messages,
- input=input,
- specific_deployment=specific_deployment,
- request_kwargs=request_kwargs,
- )
-
- strategy, strategy_selector = self._get_routing_context(model, request_kwargs)
-
- # 2. If the returned is a specific deployment (Dict), verify and return directly
- if isinstance(healthy_deployments, dict):
- if (healthy_deployments.get("model_info") or {}).get("blocked") is True:
- raise litellm.ServiceUnavailableError(
- message=f"Model '{model}' is currently paused and cannot accept requests.",
- model=model,
- llm_provider="",
- )
- litellm_params: Final = healthy_deployments.get("litellm_params", {})
- if litellm_params.get("use_in_pass_through"):
- self._override_selector_pre_call_check(strategy, strategy_selector, healthy_deployments)
- return healthy_deployments
- else:
- # Specific deployment does not support pass-through
- raise litellm.BadRequestError(
- message=f"Deployment {healthy_deployments.get('model_info', {}).get('id')} does not support pass-through endpoint (use_in_pass_through=False)",
- model=model,
- llm_provider="",
- )
-
- # 3. Filter deployments that support pass-through
- pass_through_deployments = self._filter_pass_through_deployments(healthy_deployments=healthy_deployments)
-
- if len(pass_through_deployments) == 0:
- # No deployments support pass-through
- raise litellm.BadRequestError(
- message=f"Model {model} has no deployment configured with use_in_pass_through=True. Please add use_in_pass_through: true in the deployment configuration",
- model=model,
- llm_provider="",
- )
- pass_through_model_ids: Final = tuple(
- deployment["model_info"]["id"]
- for deployment in pass_through_deployments
- if "id" in deployment.get("model_info", {})
- )
-
- # 4. Apply health-check and cooldown filtering
- parent_otel_span: Final[Span | None] = get_parent_otel_span_from_kwargs(request_kwargs)
- request_deployments: Final = cast( # cast-ok: router deployments are plain dicts
- Sequence[DeploymentTypedDict], pass_through_deployments
- )
- pass_through_deployments = self._filter_health_check_unhealthy_deployments(
- healthy_deployments=pass_through_deployments,
- parent_otel_span=parent_otel_span,
- )
- cooldown_deployments: Final = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
- pass_through_deployments = self._filter_cooldown_deployments(
- healthy_deployments=pass_through_deployments,
- cooldown_deployments=cooldown_deployments,
- )
- pass_through_deployments = self._filter_blocked_deployments(pass_through_deployments)
-
- # 5. Apply pre-call checks (if enabled)
- if self._core_enable_pre_call_checks and (messages is not None or input is not None):
- pass_through_deployments = self._pre_call_checks(
- model=model,
- healthy_deployments=pass_through_deployments,
- messages=messages,
- input=input,
- request_kwargs=request_kwargs,
- request_deployments=request_deployments,
- )
-
- if self._is_priority_routing_group(model):
- pass_through_deployments = litellm.utils.get_order_filtered_deployments(
- pass_through_deployments,
- target_order=request_kwargs.pop("_target_order", None) if request_kwargs is not None else None,
- )
-
- if len(pass_through_deployments) == 0:
- model_ids = self._core_get_model_ids(model_name=model)
- _cooldown_time = self._core_cooldown_cache.get_min_cooldown(
- model_ids=model_ids, parent_otel_span=parent_otel_span
- )
- _cooldown_list = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
- raise RouterRateLimitError(
- model=model,
- cooldown_time=_cooldown_time,
- enable_pre_call_checks=self._core_enable_pre_call_checks,
- cooldown_list=_cooldown_list,
- model_ids=pass_through_model_ids,
- cooldown_deployments=get_cooldown_deployment_info(
- self._core_ctx,
- self._deployment_ids(
- self._region_routable_deployments(
- request_deployments,
- cast(Mapping[str, object] | None, request_kwargs), # cast-ok: str-keyed request kwargs
- messages is not None or input is not None,
- )
- ),
- _cooldown_list,
- ),
- )
-
- # 6. Apply load balancing strategy
- if strategy == "simple-shuffle":
- return simple_shuffle(
- resolve_model_alias=self._core_get_model_from_alias,
- healthy_deployments=pass_through_deployments,
- model=model,
- request_kwargs=request_kwargs,
- )
- deployment: Final = self._select_deployment_sync(
- strategy=strategy,
- selector=strategy_selector,
- model=model,
- healthy_deployments=pass_through_deployments,
- messages=messages,
- input=input,
- request_kwargs=request_kwargs,
- )
-
- if deployment is None:
- verbose_router_logger.info(
- "get_available_deployment_for_pass_through model: %s, no available deployments", model
- )
- model_ids = self._core_get_model_ids(model_name=model)
- _cooldown_time = self._core_cooldown_cache.get_min_cooldown(
- model_ids=model_ids, parent_otel_span=parent_otel_span
- )
- _cooldown_list = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
- raise RouterRateLimitError(
- model=model,
- cooldown_time=_cooldown_time,
- enable_pre_call_checks=self._core_enable_pre_call_checks,
- cooldown_list=_cooldown_list,
- model_ids=model_ids,
- )
- self._override_selector_pre_call_check(strategy, strategy_selector, deployment)
-
- verbose_router_logger.info(
- "get_available_deployment_for_pass_through model: %s, selected deployment: %s",
- model,
- self.print_deployment(deployment),
- )
- return deployment
-
- def _filter_cooldown_deployments(
- self, healthy_deployments: list[dict], cooldown_deployments: list[str]
- ) -> list[dict]:
- """
- Filters out the deployments currently cooling down from the list of healthy deployments
-
- Args:
- healthy_deployments: List of healthy deployments
- cooldown_deployments: List of model_ids cooling down. cooldown_deployments is a list of model_id's cooling down, cooldown_deployments = ["16700539-b3cd-42f4-b426-6a12a1bb706a", "16700539-b3cd-42f4-b426-7899"]
-
- Returns:
- List of healthy deployments
- """
- if verbose_router_logger.isEnabledFor(logging.DEBUG):
- verbose_router_logger.debug("cooldown deployments: %s", cooldown_deployments)
- # Convert to set for O(1) lookup and use list comprehension for O(n) filtering
- cooldown_set: Final = set(cooldown_deployments)
- return [deployment for deployment in healthy_deployments if deployment["model_info"]["id"] not in cooldown_set]
-
- def _filter_blocked_deployments(self, healthy_deployments: list[dict]) -> list[dict]:
- """
- Filters out deployments that an admin has paused via `LiteLLM_ProxyModelTable.blocked`.
-
- Applied alongside the cooldown filter on every routing entry point that calls
- `_common_checks_available_deployment` directly — the primary sync/async path,
- the sync pass-through path, and the retry / health-check helpers — so paused
- deployments never serve a request. The async pass-through path inherits this
- filter through its delegation to `async_get_healthy_deployments`.
- """
- return [
- deployment
- for deployment in healthy_deployments
- if (deployment.get("model_info") or {}).get("blocked") is not True
- ]
-
- @staticmethod
- def _core_is_deployment_blocked(deployment: "Deployment") -> bool:
- """
- Returns True when a `Deployment` Pydantic instance carries the admin-paused
- flag. Used by credential-lookup helpers so passthrough file / batch endpoints
- cannot bypass the pause by resolving credentials directly.
- """
- model_info: Final[object | None] = getattr(deployment, "model_info", None)
- if model_info is None:
- return False
- return getattr(model_info, "blocked", None) is True
-
- async def _async_filter_health_check_unhealthy_deployments(
- self,
- healthy_deployments: list[dict],
- parent_otel_span: Span | None = None,
- health_check_probe: bool = False,
- ) -> list[dict]:
- """
- Filter out deployments marked unhealthy by background health checks.
- No-op when enable_health_check_routing is False.
- When background_health_check_model_groups is set, only deployments in the
- listed model groups are filtered; every other group keeps its configured
- routing strategy untouched, and a router-level allowed_fails_policy no
- longer disables the filter for the listed groups.
- Returns all deployments if health state is unavailable, stale, or would
- exclude every candidate (safety net).
- """
- if not self._core_enable_health_check_routing:
- return healthy_deployments
-
- # When allowed_fails_policy is set, cooldown is the sole routing exclusion
- # mechanism -- skip the binary health check filter so the policy threshold
- # is respected before any deployment is excluded. With a model-group
- # allowlist the filter is already scoped, so listed groups keep it.
- scoped_groups: Final = self._core_background_health_check_model_groups
- if self._core_allowed_fails_policy is not None and scoped_groups is None:
- return healthy_deployments
-
- unhealthy_ids: Final = await self._core_health_state_cache.async_get_unhealthy_deployment_ids(
- parent_otel_span=parent_otel_span
- )
- if not unhealthy_ids:
- return healthy_deployments
-
- filtered: Final = [
- d
- for d in healthy_deployments
- if d["model_info"]["id"] not in unhealthy_ids
- or (scoped_groups is not None and d["model_name"] not in scoped_groups)
- ]
-
- if not filtered:
- return [] if health_check_probe else healthy_deployments
-
- return filtered
-
- def _filter_health_check_unhealthy_deployments(
- self,
- healthy_deployments: list[dict],
- parent_otel_span: Span | None = None,
- ) -> list[dict]:
- """Sync version of _async_filter_health_check_unhealthy_deployments."""
- if not self._core_enable_health_check_routing:
- return healthy_deployments
-
- scoped_groups: Final = self._core_background_health_check_model_groups
- if self._core_allowed_fails_policy is not None and scoped_groups is None:
- return healthy_deployments
-
- unhealthy_ids: Final = self._core_health_state_cache.get_unhealthy_deployment_ids(
- parent_otel_span=parent_otel_span
- )
- if not unhealthy_ids:
- return healthy_deployments
-
- filtered: Final = [
- d
- for d in healthy_deployments
- if d["model_info"]["id"] not in unhealthy_ids
- or (scoped_groups is not None and d["model_name"] not in scoped_groups)
- ]
-
- if not filtered:
- verbose_router_logger.warning("All deployments marked unhealthy by health checks, bypassing health filter")
- return healthy_deployments
-
- return filtered
-
- def _filter_pass_through_deployments(self, healthy_deployments: list[dict]) -> list[dict]:
- """
- Filter out deployments configured with use_in_pass_through=True
-
- Args:
- healthy_deployments: List of healthy deployments
-
- Returns:
- List[Dict]: Only includes a list of deployments that support pass-through
- """
- verbose_router_logger.debug(
- "Filter pass-through deployments from %s healthy deployments", len(healthy_deployments)
- )
-
- pass_through_deployments: Final = [
- deployment
- for deployment in healthy_deployments
- if deployment.get("litellm_params", {}).get("use_in_pass_through", False)
- ]
-
- verbose_router_logger.debug("Found %s deployments with pass-through enabled", len(pass_through_deployments))
-
- return pass_through_deployments
-
- def _track_deployment_metrics(self, deployment, parent_otel_span: Span | None, response=None):
- """
- Tracks successful requests rpm usage.
- """
- try:
- model_id: Final = deployment.get("model_info", {}).get("id", None)
- if response is None:
- # update self.deployment_stats
- if model_id is not None:
- self._update_usage(model_id, parent_otel_span) # update in-memory cache for tracking
- except Exception as e:
- verbose_router_logger.error("Error in _track_deployment_metrics: %s", e)
-
- def get_num_retries_from_retry_policy(self, exception: Exception, model_group: str | None = None):
- return _get_num_retries_from_retry_policy(
- exception=exception,
- model_group=model_group,
- model_group_retry_policy=self.model_group_retry_policy,
- retry_policy=self.retry_policy,
- )
-
- def _core_get_allowed_fails_from_policy(self, exception: Exception) -> int | None:
- """
- BadRequestErrorRetries: Optional[int] = None
- AuthenticationErrorRetries: Optional[int] = None
- TimeoutErrorRetries: Optional[int] = None
- RateLimitErrorRetries: Optional[int] = None
- ContentPolicyViolationErrorRetries: Optional[int] = None
- """
- # if we can find the exception then in the retry policy -> return the number of retries
- allowed_fails_policy: Final[AllowedFailsPolicy | None] = self._core_allowed_fails_policy
-
- if allowed_fails_policy is None:
- return None
-
- if (
- isinstance(exception, litellm.AuthenticationError)
- and allowed_fails_policy.AuthenticationErrorAllowedFails is not None
- ):
- return allowed_fails_policy.AuthenticationErrorAllowedFails
- if isinstance(exception, litellm.Timeout) and allowed_fails_policy.TimeoutErrorAllowedFails is not None:
- return allowed_fails_policy.TimeoutErrorAllowedFails
- if (
- isinstance(exception, litellm.RateLimitError)
- and allowed_fails_policy.RateLimitErrorAllowedFails is not None
- ):
- return allowed_fails_policy.RateLimitErrorAllowedFails
- if (
- isinstance(exception, litellm.ContentPolicyViolationError)
- and allowed_fails_policy.ContentPolicyViolationErrorAllowedFails is not None
- ):
- return allowed_fails_policy.ContentPolicyViolationErrorAllowedFails
- if (
- isinstance(exception, litellm.BadRequestError)
- and allowed_fails_policy.BadRequestErrorAllowedFails is not None
- ):
- return allowed_fails_policy.BadRequestErrorAllowedFails
- if (
- isinstance(exception, litellm.InternalServerError)
- and allowed_fails_policy.InternalServerErrorAllowedFails is not None
- ):
- return allowed_fails_policy.InternalServerErrorAllowedFails
- if (
- isinstance(exception, litellm.ServiceUnavailableError)
- and allowed_fails_policy.ServiceUnavailableErrorAllowedFails is not None
- ):
- return allowed_fails_policy.ServiceUnavailableErrorAllowedFails
- if (
- isinstance(exception, litellm.BadGatewayError)
- and allowed_fails_policy.BadGatewayErrorAllowedFails is not None
- ):
- return allowed_fails_policy.BadGatewayErrorAllowedFails
- if isinstance(exception, litellm.NotFoundError) and allowed_fails_policy.NotFoundErrorAllowedFails is not None:
- return allowed_fails_policy.NotFoundErrorAllowedFails
-
- def _initialize_alerting(self):
- from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
-
- if self.alerting_config is None:
- return
-
- router_alerting_config: Final[AlertingConfig] = self.alerting_config
-
- _slack_alerting_logger: Final = SlackAlerting(
- alerting_threshold=router_alerting_config.alerting_threshold,
- alerting=["slack"],
- default_webhook_url=router_alerting_config.webhook_url,
- )
-
- self._core_slack_alerting_logger = _slack_alerting_logger
-
- litellm.logging_callback_manager.add_litellm_callback(_slack_alerting_logger)
- litellm.logging_callback_manager.add_litellm_success_callback(
- _slack_alerting_logger.response_taking_too_long_callback
- )
- verbose_router_logger.info("\033[94m\nInitialized Alerting for litellm.Router\033[0m\n")
-
- def _core_set_custom_routing_strategy(self, CustomRoutingStrategy: CustomRoutingStrategyBase) -> None:
- """
- Sets get_available_deployment and async_get_available_deployment on an instanced of litellm.Router
-
- Use this to set your custom routing strategy
-
- Args:
- CustomRoutingStrategy: litellm.router.CustomRoutingStrategyBase
- """
-
- strategy: Final = CustomRoutingStrategy
- setattr(
- self,
- "get_available_deployment",
- resolve_pods_after_bound(
- strategy.get_available_deployment, # pyright: ignore[reportUnknownArgumentType] # legacy stub
- self._core_kubernetes_pod_discovery,
- ),
- )
- setattr(
- self,
- "_core_async_get_available_deployment",
- async_resolve_pods_after_bound(
- strategy.async_get_available_deployment, # pyright: ignore[reportUnknownArgumentType] # legacy stub
- self._core_kubernetes_pod_discovery,
- ),
- )
-
- def _reset_custom_routing_strategy(self) -> None:
- for attr in ("get_available_deployment", "_core_async_get_available_deployment"):
- if attr in self.__dict__:
- delattr(self, attr)
-
- def flush_cache(self):
- litellm.cache = None
- self._core_cache.flush_cache()
- session_in_memory_cache: Final = self._claude_code_session_router_cache.in_memory_cache
- if session_in_memory_cache is not None:
- session_in_memory_cache.flush_cache()
- self._core_prompt_caching_pin_cache.in_memory_cache.flush_cache()
-
- def reset(self):
- ## clean up on close
- litellm.success_callback = []
- litellm._async_success_callback = []
- litellm.failure_callback = []
- litellm._async_failure_callback = []
- self.retry_policy = None
- self.flush_cache()
-
-
-def _name_core_members_after_old_names() -> None:
- # An old member that moved to ``_core_`` can still be reached as the function itself
- # (``cached_deployment_model_info`` is an lru_cache over one), so errors that name the method
- # keep the name it had before.
- for name, member in vars(Router).items():
- old_name = name.removeprefix("_core_")
- if old_name != name and inspect.isfunction(member) and hasattr(DeprecatedRouterMembers, old_name):
- member.__qualname__ = f"Router.{old_name}"
-
-
-_name_core_members_after_old_names()
+ Router.__init__.__doc__ = PythonCore.__init__.__doc__
+
+
+def __getattr__(name: str) -> object:
+ """Module-level helpers that lived in this module before the core moved out."""
+ return getattr(router_python_core, name)
+
+
+__all__ = [
+ "AllowedFailsPolicy",
+ "AssistantsTypedDict",
+ "CustomRoutingStrategyBase",
+ "Deployment",
+ "LiteLLM_Params",
+ "ModelGroupInfo",
+ "RetryPolicy",
+ "Router",
+ "as_output_cap",
+ "model_info_is_active_for_environment",
+ "router_arguments",
+ "stamped_client_ceiling",
+]
diff --git a/litellm/router_api.py b/litellm/router_api.py
index e4939254b81..c013a99573f 100644
--- a/litellm/router_api.py
+++ b/litellm/router_api.py
@@ -42,6 +42,8 @@ from dataclasses import dataclass, field
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Literal, Protocol, TypeAlias, overload
+from litellm.router_config import detached
+
if TYPE_CHECKING:
from typing import Any # noqa: TID251 # only to spell the cores' untyped (Unknown) containers and one Any type
@@ -176,9 +178,9 @@ def deployment_usable_by_team(deployment: "Mapping[str, object] | Deployment", t
Whether ``team_id`` may use ``deployment``: a team-scoped deployment (``model_info.team_id``
set) only by that team, any other deployment by everyone. Needs no router instance.
"""
- from litellm.router import Router
+ from litellm.router_python_core import PythonCore
- return Router._core_deployment_usable_by_team(deployment, team_id)
+ return PythonCore._core_deployment_usable_by_team(deployment, team_id)
def deployment_id(model_group: str, litellm_params: Mapping[str, object]) -> str:
@@ -200,9 +202,9 @@ def deployment_id(model_group: str, litellm_params: Mapping[str, object]) -> str
def router_init_args() -> tuple[str, ...]:
"""The arguments ``Router.__init__`` accepts (was ``Router.get_valid_args``). Needs no router instance."""
- from litellm.router import Router
+ from litellm.router_python_core import PythonCore
- return tuple(Router.get_valid_args())
+ return tuple(PythonCore.get_valid_args())
def mark_advisor_orchestration_failure(exception: BaseException) -> None:
@@ -266,9 +268,9 @@ def deployment_metadata(request_kwargs: Mapping[str, object]) -> Mapping[str, ob
def _json_default_stable_id(obj: object) -> str:
- from litellm.router import Router
+ from litellm.router_python_core import PythonCore
- return Router._json_default_stable_id(obj)
+ return PythonCore._json_default_stable_id(obj)
class RouterRegistry:
@@ -529,10 +531,10 @@ class RouterRegistry:
``should_include_deployment``), or, without ``model_name``, whether the team may use it at
all (was ``deployment_usable_by_team``).
"""
- from litellm.router import Router
+ from litellm.router_python_core import PythonCore
if model_name is None:
- return Router._core_deployment_usable_by_team(deployment, team_id)
+ return PythonCore._core_deployment_usable_by_team(deployment, team_id)
return self._router._core_should_include_deployment(model_name=model_name, model=deployment, team_id=team_id)
@overload
@@ -1053,6 +1055,12 @@ SETTING_FIELDS: Final[tuple[str, ...]] = (
"cache_responses",
)
+# Runtime settings kept as plain router attributes rather than ``settings()`` fields. A write
+# to one (``router.num_retries = 2``) is a changeset too: ``ChangeSet(assign={...})``.
+PLAIN_SETTING_ATTRIBUTES: Final[frozenset[str]] = frozenset({"num_retries", "max_fallbacks", "retry_after", "timeout"})
+# Former attributes whose value lives under another name, assignable through a changeset.
+ASSIGNABLE_ALIASES: Final[Mapping[str, str]] = MappingProxyType({"model_group_alias": "_core_model_group_alias"})
+
_MISSING: Final = object()
@@ -1168,16 +1176,16 @@ class RouterHealth:
@staticmethod
def is_blocked(deployment: "Deployment") -> bool:
"""Whether an admin paused this deployment (was ``is_deployment_blocked``)."""
- from litellm.router import Router
+ from litellm.router_python_core import PythonCore
- return Router._core_is_deployment_blocked(deployment)
+ return PythonCore._core_is_deployment_blocked(deployment)
@staticmethod
def all_blocked(deployments: "Sequence[DeploymentTypedDict]") -> bool:
"""Whether every deployment in the list is paused (was ``are_all_deployments_blocked``)."""
- from litellm.router import Router
+ from litellm.router_python_core import PythonCore
- return Router._core_are_all_deployments_blocked(deployments)
+ return PythonCore._core_are_all_deployments_blocked(deployments)
def fully_blocked_model_names(self) -> set[str]: # mutable-ok: the core's type, a fresh set
"""Groups whose every deployment is paused (was ``get_fully_blocked_model_names``)."""
@@ -1296,13 +1304,17 @@ class RoutingObservers:
def apply_changes(router: "Router", changes: ChangeSet) -> ApplyResult:
if changes.refresh_model_info:
raise TypeError("ChangeSet(refresh_model_info=True) needs aapply()")
- unknown: Final = sorted(set(changes.assign) - set(SETTING_FIELDS))
+ unknown: Final = sorted(set(changes.assign) - {*SETTING_FIELDS, *PLAIN_SETTING_ATTRIBUTES, *ASSIGNABLE_ALIASES})
if unknown:
raise ValueError(f"not assignable router settings: {unknown}")
for name, value in changes.assign.items():
- setattr(router, "_core_" + name, value)
+ setattr(
+ router,
+ name if name in PLAIN_SETTING_ATTRIBUTES else ASSIGNABLE_ALIASES.get(name, "_core_" + name),
+ detached(value),
+ )
if changes.settings:
- router._core_update_settings(**changes.settings)
+ router._core_update_settings(**{name: detached(value) for name, value in changes.settings.items()})
upserted: Final = [router._core_upsert_deployment(deployment=d) for d in changes.upsert]
deleted: Final = [router._core_delete_deployment(id=i) for i in changes.delete]
if changes.redis_cache is not None:
@@ -1388,7 +1400,7 @@ class RoutingContext:
return self._router.observe()
def belongs_to(self, router: "Router") -> bool:
- return self._router is router
+ return self._router is router or router.__dict__.get("_core") is self._router
@property
def explicit_allowed_fails(self) -> int | None:
diff --git a/litellm/router_config.py b/litellm/router_config.py
new file mode 100644
index 00000000000..75c79068191
--- /dev/null
+++ b/litellm/router_config.py
@@ -0,0 +1,157 @@
+"""
+The router's configuration, built outside the router.
+
+``RouterConfig`` is static: the constructor arguments, frozen for the router's life.
+``RouterDynamicConfig`` is a snapshot of the process settings the router reads while it serves
+(``litellm.num_retries``, ``litellm.model_cost``, the fallback lists, ...). A call reads one snapshot
+at its start. This module is the only router code that reads ``litellm.*`` settings.
+"""
+
+import itertools
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass, field
+from types import MappingProxyType
+from typing import TYPE_CHECKING, Final
+
+import litellm
+
+if TYPE_CHECKING:
+ from litellm.caching.caching import Cache
+ from litellm.litellm_core_utils.logging_callback_manager import LoggingCallbackManager
+
+
+@dataclass(frozen=True, slots=True)
+class RouterConfig:
+ """The arguments the router was built with, exactly as passed (unset ones are absent)."""
+
+ arguments: Mapping[str, object] = field(default_factory=lambda: MappingProxyType({}))
+
+ def get(self, name: str, default: object = None) -> object:
+ return self.arguments.get(name, default)
+
+
+@dataclass(frozen=True, slots=True)
+class RouterDynamicConfig:
+ """Process settings the router reads per call. Values are the live objects, not copies."""
+
+ version: int
+ num_retries: int | None
+ max_fallbacks: int | None
+ router_max_fallbacks: int
+ allowed_fails: int
+ request_timeout: float
+ fallbacks: Sequence[object] | None
+ default_fallbacks: Sequence[str] | None
+ context_window_fallbacks: Sequence[object] | None
+ content_policy_fallbacks: Sequence[object] | None
+ drop_params: bool | None
+ provider_list: Sequence[str]
+ expose_router_debug_in_errors: bool
+ redact_user_api_key_info: bool | None
+ model_alias_map: Mapping[str, str]
+ model_cost: Mapping[str, object]
+ known_custom_logger_compatible_callbacks: Sequence[str]
+ exception_types: Sequence[type[BaseException]]
+ callbacks: Sequence[object]
+ success_callback: Sequence[object]
+ failure_callback: Sequence[object]
+ input_callback: Sequence[object]
+ callback_manager: "LoggingCallbackManager"
+ cache: "Cache | None"
+
+
+_versions: Final = itertools.count(1)
+
+
+def detached(value: object) -> object:
+ """``value`` with its plain containers (dict, list, tuple, set) copied, all the way down, so the
+ caller's own objects can't change the router afterwards. Other objects (clients, caches,
+ callbacks, pydantic models) are kept as they are."""
+ if isinstance(value, dict):
+ return {key: detached(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [detached(item) for item in value]
+ if isinstance(value, tuple) and type(value) is tuple:
+ return tuple(detached(item) for item in value)
+ if isinstance(value, set):
+ return {detached(item) for item in value}
+ return value
+
+
+def build_router_config(arguments: Mapping[str, object]) -> RouterConfig:
+ """The constructor arguments, detached from the caller's objects: settings are static."""
+ return RouterConfig(arguments=MappingProxyType({name: detached(value) for name, value in arguments.items()}))
+
+
+def current_dynamic_config() -> RouterDynamicConfig:
+ """A snapshot of the process settings as they are right now."""
+ return RouterDynamicConfig(
+ version=next(_versions),
+ num_retries=litellm.num_retries,
+ max_fallbacks=litellm.max_fallbacks,
+ router_max_fallbacks=litellm.ROUTER_MAX_FALLBACKS,
+ allowed_fails=litellm.allowed_fails,
+ request_timeout=litellm.request_timeout,
+ fallbacks=litellm.fallbacks,
+ default_fallbacks=litellm.default_fallbacks,
+ context_window_fallbacks=litellm.context_window_fallbacks,
+ content_policy_fallbacks=litellm.content_policy_fallbacks,
+ drop_params=litellm.drop_params,
+ provider_list=litellm.provider_list,
+ expose_router_debug_in_errors=litellm.expose_router_debug_in_errors,
+ redact_user_api_key_info=litellm.redact_user_api_key_info,
+ model_alias_map=litellm.model_alias_map,
+ model_cost=litellm.model_cost,
+ known_custom_logger_compatible_callbacks=litellm._known_custom_logger_compatible_callbacks,
+ exception_types=litellm.LITELLM_EXCEPTION_TYPES,
+ callbacks=litellm.callbacks,
+ success_callback=litellm.success_callback,
+ failure_callback=litellm.failure_callback,
+ input_callback=litellm.input_callback,
+ callback_manager=litellm.logging_callback_manager,
+ cache=litellm.cache,
+ )
+
+
+_LIVE_SOURCES: Final = MappingProxyType(
+ {
+ "num_retries": "num_retries",
+ "max_fallbacks": "max_fallbacks",
+ "router_max_fallbacks": "ROUTER_MAX_FALLBACKS",
+ "allowed_fails": "allowed_fails",
+ "request_timeout": "request_timeout",
+ "fallbacks": "fallbacks",
+ "default_fallbacks": "default_fallbacks",
+ "context_window_fallbacks": "context_window_fallbacks",
+ "content_policy_fallbacks": "content_policy_fallbacks",
+ "drop_params": "drop_params",
+ "provider_list": "provider_list",
+ "expose_router_debug_in_errors": "expose_router_debug_in_errors",
+ "redact_user_api_key_info": "redact_user_api_key_info",
+ "model_alias_map": "model_alias_map",
+ "model_cost": "model_cost",
+ "known_custom_logger_compatible_callbacks": "_known_custom_logger_compatible_callbacks",
+ "exception_types": "LITELLM_EXCEPTION_TYPES",
+ "callbacks": "callbacks",
+ "success_callback": "success_callback",
+ "failure_callback": "failure_callback",
+ "input_callback": "input_callback",
+ "callback_manager": "logging_callback_manager",
+ "cache": "cache",
+ }
+)
+
+
+class LiveDynamicConfig:
+ """``RouterDynamicConfig``'s fields read from the process settings at access time, with no snapshot."""
+
+ __slots__ = ()
+
+ def __getattr__(self, name: str) -> object:
+ source: Final = _LIVE_SOURCES.get(name)
+ if source is None:
+ raise AttributeError(name)
+ return getattr(litellm, source)
+
+
+live_dynamic_config: Final = LiveDynamicConfig()
diff --git a/litellm/router_core.py b/litellm/router_core.py
new file mode 100644
index 00000000000..acbe82a0643
--- /dev/null
+++ b/litellm/router_core.py
@@ -0,0 +1,56 @@
+"""
+``RouterCore``: the interface ``litellm.Router`` delegates to, and ``make_core``, which picks one.
+
+``PythonCore`` (``litellm/router_python_core.py``) is today's routing logic. The Rust core
+(litellm-rust/crates/router-core) implements the same operations. The core is picked once, when
+the router is built, from its ``RouterConfig``.
+"""
+
+from collections.abc import Mapping
+from typing import TYPE_CHECKING, Protocol, runtime_checkable
+
+from litellm.router_config import RouterConfig
+
+if TYPE_CHECKING:
+ from litellm.router_api import (
+ ApplyResult,
+ ChangeSet,
+ RouterHealth,
+ RouterRegistry,
+ RouterSettings,
+ RoutingObservers,
+ )
+ from litellm.router_python_core import PythonCore
+ from litellm.router_python_host import PythonHost
+ from litellm.types.router import CustomRoutingStrategyBase
+
+
+@runtime_checkable
+class RouterCore(Protocol):
+ async def call(self, op: str, request: Mapping[str, object]) -> object: ...
+
+ def registry(self) -> "RouterRegistry": ...
+
+ def settings(self) -> "RouterSettings": ...
+
+ def health(self) -> "RouterHealth": ...
+
+ async def select(
+ self,
+ model: str,
+ request_kwargs: dict[str, object] | None = None, # mutable-ok: the selection cores take a dict
+ ) -> object: ...
+
+ def apply(self, changes: "ChangeSet") -> "ApplyResult": ...
+
+ def observe(self) -> "RoutingObservers": ...
+
+ def register_plugin(self, plugin: "CustomRoutingStrategyBase") -> None: ...
+
+ def retire(self) -> None: ...
+
+
+def make_core(config: RouterConfig, host: "PythonHost", core_class: "type[PythonCore] | None" = None) -> RouterCore:
+ from litellm.router_python_core import PythonCore
+
+ return (core_class or PythonCore)(**config.arguments, _host=host) # pyright: ignore[reportArgumentType] # the arguments were bound against PythonCore's own signature
diff --git a/litellm/router_deprecated.py b/litellm/router_deprecated.py
index 0b328cc3e6b..83a1251d3c0 100644
--- a/litellm/router_deprecated.py
+++ b/litellm/router_deprecated.py
@@ -31,7 +31,7 @@ from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Generic, TypeVar, overload
-from litellm.router_api import SETTING_FIELDS, ChangeSet
+from litellm.router_api import ASSIGNABLE_ALIASES, SETTING_FIELDS, ChangeSet
if TYPE_CHECKING:
from typing import Any # noqa: TID251 # only to repeat two old signatures that had Any, unchanged
@@ -65,7 +65,7 @@ _INTERNAL_PREFIX: Final = "litellm.router"
# Router's own attribute hooks: a write or delete through an old name passes through them first.
-_ATTRIBUTE_HOOKS: Final = frozenset({"__setattr__", "__delattr__"})
+_ATTRIBUTE_HOOKS: Final = frozenset({"__getattribute__", "__setattr__", "__delattr__"})
def _note(name: str, replacement: str) -> None:
@@ -88,9 +88,10 @@ def _note(name: str, replacement: str) -> None:
def _as_router(obj: "DeprecatedRouterMembers") -> "Router":
"""The Router this mixin is part of (it is mixed into ``litellm.Router`` only)."""
from litellm.router import Router
+ from litellm.router_python_core import PythonCore
- if isinstance(obj, Router):
- return obj
+ if isinstance(obj, (Router, PythonCore)):
+ return obj # pyright: ignore[reportReturnType] # a PythonCore carries every member the mixin reads
raise TypeError(f"{type(obj).__name__} is not a litellm.Router; DeprecatedRouterMembers is mixed into Router only")
@@ -134,7 +135,7 @@ class _DeprecatedAttribute(Generic[_T]):
def __set__(self, obj: "DeprecatedRouterMembers", value: _T) -> None:
_note(self.name, self.replacement)
- if self.name in SETTING_FIELDS:
+ if self.name in SETTING_FIELDS or self.name in ASSIGNABLE_ALIASES:
_as_router(obj).apply(ChangeSet(assign={self.name: value}))
else:
setattr(obj, "_core_" + self.name, value)
@@ -169,6 +170,13 @@ _OVERRIDES_RUNNING: Final[contextvars.ContextVar[frozenset[tuple[int, str]]]] =
)
+def _override_key(router: object, name: str) -> tuple[int, str]:
+ """The running-override key for ``router``: a Router and its core share one, so an old member
+ entered on the Router is seen by the override routing that runs on the core."""
+ core: Final = getattr(router, "__dict__", {}).get("_core")
+ return (id(router if core is None else core), name)
+
+
def _enter_override(name: str, override: Callable[..., object]) -> Callable[..., object]:
"""
A direct call of a subclass override (``router.()``) marks it running, so its
@@ -184,7 +192,7 @@ def _enter_override(name: str, override: Callable[..., object]) -> Callable[...,
*args: object,
**kwargs: object, # kwargs-ok: passes the override's arguments through unchanged
) -> object:
- token: Final = _OVERRIDES_RUNNING.set(_OVERRIDES_RUNNING.get() | {(id(router), name)})
+ token: Final = _OVERRIDES_RUNNING.set(_OVERRIDES_RUNNING.get() | {_override_key(router, name)})
try:
return await async_override(router, *args, **kwargs)
finally:
@@ -198,7 +206,7 @@ def _enter_override(name: str, override: Callable[..., object]) -> Callable[...,
*args: object,
**kwargs: object, # kwargs-ok: passes the override's arguments through unchanged
) -> object:
- token: Final = _OVERRIDES_RUNNING.set(_OVERRIDES_RUNNING.get() | {(id(router), name)})
+ token: Final = _OVERRIDES_RUNNING.set(_OVERRIDES_RUNNING.get() | {_override_key(router, name)})
try:
return override(router, *args, **kwargs)
finally:
@@ -236,7 +244,7 @@ def _route_override(cls: type, name: str, override: Callable[..., object]) -> Ca
*args: object,
**kwargs: object, # kwargs-ok: passes the override's arguments through unchanged
) -> object:
- key: Final = (id(router), name)
+ key: Final = _override_key(router, name)
running: Final = _OVERRIDES_RUNNING.get()
if key in running:
parent_async: Final[Callable[..., Awaitable[object]]] = getattr(super(cls, router), core)
@@ -255,7 +263,7 @@ def _route_override(cls: type, name: str, override: Callable[..., object]) -> Ca
*args: object,
**kwargs: object, # kwargs-ok: passes the override's arguments through unchanged
) -> object:
- key: Final = (id(router), name)
+ key: Final = _override_key(router, name)
running: Final = _OVERRIDES_RUNNING.get()
if key in running:
return parent(router)(*args, **kwargs)
@@ -502,9 +510,9 @@ class DeprecatedRouterMembers:
@staticmethod
def deployment_usable_by_team(model: "Mapping[str, object] | Deployment", team_id: str | None) -> bool:
_note("deployment_usable_by_team", "registry().includes(deployment, team_id=...)")
- from litellm.router import Router
+ from litellm.router_python_core import PythonCore
- return Router._core_deployment_usable_by_team(model, team_id)
+ return PythonCore._core_deployment_usable_by_team(model, team_id)
_deployment_usable_by_team = deployment_usable_by_team
diff --git a/litellm/router_python_core.py b/litellm/router_python_core.py
new file mode 100644
index 00000000000..30b05456987
--- /dev/null
+++ b/litellm/router_python_core.py
@@ -0,0 +1,17669 @@
+# +-----------------------------------------------+
+# | |
+# | Give Feedback / Get Help |
+# | https://github.com/BerriAI/litellm/issues/new |
+# | |
+# +-----------------------------------------------+
+#
+# Thank you ! We ❤️ you! - Krrish & Ishaan
+
+"""
+``PythonCore``: the Python implementation of the router core behind ``litellm.Router``.
+
+It holds all routing logic (registry, selection, retries, fallbacks, cooldowns, usage) and reaches
+outside itself only through its ``PythonHost``: inference calls, sleeps, callback registration and
+the process settings (``litellm/router_config.py``). The Rust core in litellm-rust/crates/router-core
+implements the same interface. ``litellm/router.py`` picks one at construction and delegates to it.
+"""
+
+import asyncio
+import contextlib
+import copy
+import enum
+import inspect
+import json
+import logging
+import re
+import threading
+import time
+import traceback
+import weakref
+from collections import defaultdict
+from collections.abc import (
+ AsyncGenerator,
+ AsyncIterable,
+ AsyncIterator,
+ Awaitable,
+ Callable,
+ Generator,
+ Iterable,
+ Iterator,
+ Mapping,
+ MutableMapping,
+ Sequence,
+)
+from contextvars import ContextVar
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from functools import lru_cache, partial
+from itertools import chain
+from types import MappingProxyType
+from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NoReturn, Optional, TypeAlias, TypeVar, Union, cast
+
+import anyio
+import httpx
+import openai
+from openai import AsyncOpenAI
+from pydantic import BaseModel, TypeAdapter, ValidationError
+from typing_extensions import assert_never, overload
+
+from litellm import exceptions as litellm_exceptions
+from litellm import get_secret_str
+from litellm import utils as litellm_utils
+from litellm._internal_context import in_mirrored_call, mirrored_call, service_target, with_service_target
+from litellm._logging import verbose_router_logger
+from litellm._uuid import uuid
+from litellm.caching.caching import Cache as LitellmCache
+from litellm.caching.caching import (
+ DualCache,
+ InMemoryCache,
+ RedisCache,
+ RedisClusterCache,
+)
+from litellm.caching.redis_cache import log_redis_failure
+from litellm.constants import (
+ BAGGAGE_MAPPED_TAGS_METADATA_KEY,
+ CLIENT_OUTPUT_CEILING_METADATA_KEY,
+ CONSUMED_REQUEST_TAGS_METADATA_KEY,
+ DEFAULT_AUTO_ROUTER_MAX_INPUT_CHARS,
+ DEFAULT_HEALTH_CHECK_INTERVAL,
+ DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER,
+ DEFAULT_MAX_LRU_CACHE_SIZE,
+ INTERNAL_CALL_ORIGIN_METADATA_KEY,
+ KUBERNETES_POD_ROUTING_KEY,
+ MID_STREAM_FAILURE_LOG_WAIT_SECONDS,
+ OUTPUT_TOKEN_CEILING_PARAMS,
+ PROMPT_CACHE_PIN_CACHE_MAX_SIZE,
+ ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY,
+ ROUTING_REQUEST_TAGS_METADATA_KEY,
+ RUNTIME_UPDATABLE_ROUTER_SETTINGS,
+ SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
+)
+from litellm.integrations.custom_guardrail import CustomGuardrail, is_guardrail_intervention
+from litellm.integrations.custom_logger import CustomLogger
+from litellm.integrations.otel.routing import routing_decision_attributes
+from litellm.integrations.otel.runtime import phase_attributes, phase_event, phase_span
+from litellm.litellm_core_utils import exception_mapping_utils
+from litellm.litellm_core_utils.access_windows import access_windows_config_error, filter_reserved_deployments
+from litellm.litellm_core_utils.add_retry_fallback_headers import (
+ add_fallback_headers_to_response,
+ add_retry_headers_to_response,
+ apply_quality_router_decision_headers,
+ apply_remaining_usage_headers,
+ apply_response_model_id,
+ complexity_router_decision_headers,
+ ensure_response_additional_headers,
+ get_hidden_params_dict,
+ prepare_response_for_header_attachment,
+ replace_complexity_router_headers,
+ response_total_token_count,
+)
+from litellm.litellm_core_utils.asyncify import run_async_function
+from litellm.litellm_core_utils.batch_utils import (
+ batch_create_routing,
+ deployment_supported_endpoints,
+ get_router_metadata_variable_name,
+ is_batch_retrieve_call_type,
+ replace_model_in_jsonl,
+ should_replace_model_in_jsonl,
+ without_deployments_that_cannot_batch,
+)
+from litellm.litellm_core_utils.clientside_credential_handler import (
+ get_dynamic_litellm_params,
+ is_clientside_credential,
+)
+from litellm.litellm_core_utils.completion_timeout import CompletionTimeout
+from litellm.litellm_core_utils.core_helpers import (
+ coerce_token_limit,
+ get_litellm_metadata_from_kwargs,
+ get_metadata_variable_name_from_kwargs,
+ get_or_create_metadata_bucket,
+ get_parent_otel_span_from_kwargs,
+)
+from litellm.litellm_core_utils.coroutine_checker import coroutine_checker
+from litellm.litellm_core_utils.credential_accessor import CredentialAccessor, reject_api_key_secret_reference
+from litellm.litellm_core_utils.dd_tracing import tracer
+from litellm.litellm_core_utils.deployment_pricing import deployment_pricing_models
+from litellm.litellm_core_utils.get_litellm_params import SUPPLEMENTAL_KWARGS_KEYS
+from litellm.litellm_core_utils.get_llm_provider_logic import (
+ declared_authenticating_provider,
+ inferred_provider,
+ is_registered_custom_provider,
+ resolve_model_and_provider_for_metadata,
+)
+from litellm.litellm_core_utils.get_retry_from_policy import (
+ get_num_retries_from_retry_policy as _get_num_retries_from_retry_policy,
+)
+from litellm.litellm_core_utils.get_retry_from_policy import resolve_retry_policy
+from litellm.litellm_core_utils.hidden_params import (
+ HIDDEN_PARAMS_ATTR as _HIDDEN_PARAMS_ATTR,
+)
+from litellm.litellm_core_utils.hidden_params import (
+ get_hidden_params,
+ set_hidden_params,
+)
+from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
+from litellm.litellm_core_utils.llm_cost_calc.utils import SERVICE_TIER_COST_KEY_SUFFIXES
+from litellm.litellm_core_utils.pattern_match_deployments import PatternMatchRouter
+from litellm.litellm_core_utils.ptu_pricing import (
+ PTU_COST_ATTRIBUTION_ENV_VAR,
+ declares_ptu,
+ declares_ptu_shares,
+ is_ptu_cost_attribution_enabled,
+ ptu_config_error,
+ ptu_identity_error,
+ ptu_terms,
+ zeroed_ptu_pricing,
+)
+from litellm.litellm_core_utils.ptu_shares import (
+ PTUTeamCeiling,
+ filter_ptu_shared_deployments,
+ ptu_capacity_warning,
+ ptu_share_teams,
+ share_holder_request_kwargs,
+ team_ptu_ceiling,
+)
+from litellm.litellm_core_utils.reasoning_effort_capability import (
+ deployment_is_catalog_mapped,
+ intersect_supported_reasoning_efforts,
+ resolve_supported_reasoning_efforts,
+)
+from litellm.litellm_core_utils.request_timeout_resolver import (
+ get_configured_request_timeout,
+ get_service_tier_timeout,
+)
+from litellm.litellm_core_utils.routing_common_utils import (
+ MID_STREAM_CONTINUATION_KWARG,
+ as_retry_skipped_deployment_ids,
+ deployment_model_id_pin,
+ deployments_the_caller_may_use,
+ filter_team_based_models,
+ filter_web_search_deployments,
+ format_content_policy_fallback_hint,
+ format_content_policy_no_fallback_note,
+ format_fallback_outcome_message,
+ format_no_fallback_group_message,
+ get_request_team_id,
+ is_proxy_admin_request,
+ provider_for_generic_call,
+ request_user_api_key_auth,
+ resolve_model_group_alias,
+ team_may_use_deployment,
+ truncate_fallback_error_detail,
+ warn_on_ignored_model_capability_flags,
+ warn_on_provider_credential_mismatch,
+ with_admin_api_key_secret_reference,
+ without_model_capability_flags,
+ without_router_only_kwargs,
+)
+from litellm.litellm_core_utils.routing_groups import (
+ apply_routing_group_priority,
+ parse_routing_groups,
+ validate_routing_strategy,
+)
+from litellm.litellm_core_utils.secret_redaction import redact_string
+from litellm.litellm_core_utils.sensitive_data_masker import (
+ SensitiveDataMasker,
+ mask_sensitive_structure,
+)
+from litellm.litellm_core_utils.token_counter import (
+ offload_token_count,
+ served_openai_models,
+ single_encoding_openai_model,
+)
+from litellm.litellm_core_utils.tool_fields import TOOL_FIELDS, drop_policy
+from litellm.llms.anthropic.common_utils import AnthropicModelInfo, anthropic_error_frame_exception
+from litellm.llms.base_llm.passthrough.transformation import replace_path_segment
+from litellm.llms.base_llm.vector_store.transformation import (
+ RouterVectorStoreEmbeddingExecutor,
+ vector_store_request_metadata,
+)
+from litellm.llms.bedrock.common_utils import get_bedrock_base_model
+from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_async_httpx_client
+from litellm.llms.openai_like.json_loader import JSONProviderRegistry
+from litellm.llms.openai_like.model_info import (
+ MODEL_INFO_DISCOVERY_PROVIDERS,
+ MODEL_INFO_REFRESH_CONCURRENCY,
+ MODEL_INFO_REFRESH_SECONDS,
+ get_openai_compatible_model_info,
+)
+from litellm.main import Chat as LitellmChat
+from litellm.responses.bridge_check import keep_registered_mode
+from litellm.responses.retrieve_stream_query import requests_streamed_retrieval, without_retrieve_stream_defaults
+from litellm.router_api import (
+ PLAIN_SETTING_ATTRIBUTES,
+ SETTING_FIELDS,
+ ApplyResult,
+ ChangeSet,
+ RouterHealth,
+ RouterRegistry,
+ RouterSettings,
+ RoutingObservers,
+ SelectKind,
+ aapply_changes,
+ apply_changes,
+)
+from litellm.router_api import RoutingContext as RouterContext
+from litellm.router_api import deployment_id as _deployment_id
+from litellm.router_deprecated import DeprecatedRouterMembers
+from litellm.router_host import router_host
+from litellm.router_python_host import PythonHost, python_host
+from litellm.router_strategy.auto_router.auto_router_model_naming import (
+ AUTO_ROUTER_MODEL_PREFIX,
+ STRATEGY_ROUTER_PARAM_FIELDS,
+ GatedAutoRouterCapability,
+ capability_limit_violation,
+ claimed_capabilities,
+ classify_strategy_router_model,
+ count_capability_routers,
+)
+from litellm.router_strategy.base_routing_strategy import BaseRoutingStrategy
+from litellm.router_strategy.budget_limiter import RouterBudgetLimiting
+from litellm.router_strategy.complexity_router.context_compaction import (
+ arm_compaction,
+ compact_to_fit,
+ compaction_pending,
+ initialize_compaction_state,
+ is_native_compaction_call,
+ reject_recursive_compactor,
+ surface_for_call,
+)
+from litellm.router_strategy.least_busy import LeastBusyLoggingHandler
+from litellm.router_strategy.lowest_cost import LowestCostLoggingHandler
+from litellm.router_strategy.lowest_latency import LowestLatencyLoggingHandler
+from litellm.router_strategy.lowest_tpm_rpm import LowestTPMLoggingHandler
+from litellm.router_strategy.lowest_tpm_rpm_v2 import LowestTPMLoggingHandler_v2, PrefetchedUsage
+from litellm.router_strategy.simple_shuffle import simple_shuffle
+from litellm.router_strategy.tag_based_routing import (
+ baggage_stamp_for_serving_deployment,
+ get_deployments_for_tag,
+ get_routing_tags_from_request_kwargs,
+ get_tags_from_request_kwargs,
+ is_valid_deployment_tag,
+ tags_with_serving_deployment_tags,
+)
+from litellm.router_utils.client_initalization_utils import InitalizeCachedClient, MaxParallelRequestsLimit
+from litellm.router_utils.cooldown_cache import CooldownCache
+from litellm.router_utils.cooldown_handlers import (
+ DEFAULT_COOLDOWN_TIME_SECONDS,
+ _first_present, # pyright: ignore[reportPrivateUsage] - shared internal helper across router_utils submodules, matching the other cooldown_handlers imports on this line
+ async_get_cooldown_deployments,
+ async_get_cooldown_deployments_with_debug_info,
+ get_cooldown_deployment_info,
+ get_cooldown_deployments,
+ is_background_response_poll_not_found,
+ is_caller_scoped_auth_failure,
+ is_caller_timeout_408,
+ is_failure_outside_selected_deployment,
+ is_video_lookup_not_found,
+ mark_deployment_selection_failure,
+ set_cooldown_deployments,
+)
+from litellm.router_utils.embedding_batch_size_check import (
+ async_log_rejection_before_provider_call,
+ clear_rejected_before_provider_call,
+ deployments_admitting_embedding_batch,
+ deployments_when_none_admits_embedding_batch,
+ embedding_batch_too_large_error,
+ embedding_deployment_selection,
+ log_rejection_before_provider_call,
+)
+from litellm.router_utils.endpoint_eligibility import (
+ eligible_deployments,
+ endpoint_selection,
+ warn_on_unenforced_endpoint_flag,
+)
+from litellm.router_utils.fallback_event_handlers import (
+ MID_STREAM_FALLBACK_CONTROLS_KEY,
+ AttemptedFallbackTargets,
+ attempted_fallback_targets_from,
+ attempted_retries_for_request,
+ carry_over_pre_routing_selection,
+ carry_over_routed_deployment,
+ carry_superseded_attempt_usage,
+ check_non_standard_fallback_format,
+ clear_pre_routing_selection,
+ committed_retry_budget_for_request,
+ continuation_fallback_targets,
+ fallback_lookup_groups,
+ fallback_target_name,
+ fallback_target_overrides_request_params,
+ fallbacks_disabled_for_request,
+ get_fallback_model_group_for_lookup_groups,
+ get_pre_routing_selection,
+ has_unattempted_fallback_target,
+ is_bad_request_without_fallback,
+ mid_stream_fallback_hop_kwargs,
+ mid_stream_fallback_snapshot_kwargs,
+ mid_stream_retry_kwargs,
+ per_request_fallback_controls,
+ record_disable_fallbacks,
+ record_pre_routing_selection,
+ record_retry_attempt,
+ routed_deployment_id,
+ run_async_fallback,
+)
+from litellm.router_utils.handle_error import (
+ async_raise_no_deployment_exception,
+ send_llm_exception_alert,
+)
+from litellm.router_utils.health_state_cache import DeploymentHealthCache
+from litellm.router_utils.kubernetes_pod_discovery import (
+ KubernetesPodDiscovery,
+ async_resolve_pods_after,
+ async_resolve_pods_after_bound,
+ resolve_pods_after,
+ resolve_pods_after_bound,
+)
+from litellm.router_utils.pre_call_checks.continuation_prefill_check import (
+ MID_STREAM_CONTINUATION_MARKER,
+ RESPONSES_CONTINUATION_KWARG,
+ ContinuationOutput,
+ ContinuationPrefillDeploymentCheck,
+ ContinuedPartial,
+ ResponsesContinuation,
+ ResponsesContinuationSeam,
+ constrains_continuation,
+ constrains_responses_continuation,
+ continuation_deployments,
+ continuation_output_ceilings,
+ continued_text,
+ deployment_can_continue,
+ responses_continuation_input,
+ responses_continuation_input_for,
+)
+from litellm.router_utils.pre_call_checks.deployment_affinity_check import (
+ DeploymentAffinityCheck,
+ warn_on_unknown_model_group_affinity_flags,
+)
+from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import (
+ EncryptedContentAffinityCheck,
+)
+from litellm.router_utils.pre_call_checks.io_token_rate_limit_check import (
+ async_admit_io_token_turn,
+ async_io_token_refund_failure,
+ build_io_token_rate_limit_headers,
+ deployment_has_io_token_limits,
+ refund_stale_reservation_before_retry,
+ set_io_token_rate_limit_request_kwargs,
+ without_io_token_limits,
+)
+from litellm.router_utils.pre_call_checks.model_rate_limit_check import (
+ ModelRateLimitingCheck,
+)
+from litellm.router_utils.pre_call_checks.prompt_caching_deployment_check import (
+ PromptCachingDeploymentCheck,
+)
+from litellm.router_utils.request_priority import (
+ InvalidPriority,
+ parse_default_priority,
+ priority_error,
+ priority_header_fields,
+ request_drops_params,
+ resolve_request_priority,
+ unqueueable_priority_message,
+)
+from litellm.router_utils.router_callbacks.track_deployment_metrics import (
+ find_deployment_metadata,
+ get_counted_usage_tokens,
+ increment_deployment_failures_for_current_minute,
+ increment_deployment_successes_for_current_minute,
+)
+from litellm.router_utils.routing_read_batch import ROUTER_USAGE_TARGET, RoutingPrefetch, RoutingReadBatch
+from litellm.router_utils.stream_usage_injection import router_config_requests_stream_usage
+from litellm.scheduler import FlowItem, Scheduler
+from litellm.secret_managers.main import str_to_bool
+from litellm.types.guardrails import GuardrailEventHooks
+from litellm.types.litellm_params import STREAM_USAGE_INJECTED_FIELD, RoutingStrategyName
+from litellm.types.llms.openai import (
+ AllMessageValues,
+ ChatCompletionToolParam,
+ FileTypes,
+ OpenAIFileObject,
+ OpenAIFilesPurpose,
+)
+from litellm.types.router import (
+ CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS,
+ VALID_LITELLM_ENVIRONMENTS,
+ AlertingConfig,
+ AllowedFailsPolicy,
+ AssistantsTypedDict,
+ AutoRouterCapabilityLimit,
+ ConsumedRequestTagsStamp,
+ CredentialLiteLLMParams,
+ CustomRoutingStrategyBase,
+ Deployment,
+ DeploymentModelListingInfo,
+ DeploymentTypedDict,
+ DiscoveredDeploymentModelInfo,
+ FallbackAccessCheck,
+ FallbackBudgetCheck,
+ GenericLiteLLMParams,
+ GuardrailTypedDict,
+ LiteLLM_Params,
+ LiteLLMParamsTypedDict,
+ MockRouterTestingParams,
+ ModelGroupInfo,
+ OptionalPreCallChecks,
+ PreRoutingStrategy,
+ RetryPolicy,
+ RouterCacheEnum,
+ RouterErrors,
+ RouterGeneralSettings,
+ RouterModelGroupAliasItem,
+ RouterRateLimitError,
+ RouterRateLimitErrorBasic,
+ RoutingContext,
+ RoutingGroup,
+ RoutingPlugin,
+ RoutingStrategy,
+ SearchToolTypedDict,
+ TaggedPreRoutingStrategy,
+ holds_secret_pointer,
+ validate_litellm_params_strictly,
+)
+from litellm.types.services import ServiceTypes
+from litellm.types.utils import (
+ AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
+ PROMPT_QUOTING_ROUTING_DECISION_FIELDS,
+ CustomPricingLiteLLMParams,
+ GenericBudgetConfigType,
+ LiteLLMBatch,
+ LlmProviders,
+ ModelInfo,
+ ModelResponseStream,
+ StandardLoggingPayload,
+ StandardLoggingRoutingDecision,
+ Usage,
+ all_litellm_params,
+ is_server_derived_pricing_key,
+ shared_backend_model_info,
+)
+from litellm.types.utils import ModelInfo as ModelMapInfo
+from litellm.types.videos.utils import VIDEO_ID_CALL_TYPES, decode_video_id_with_provider
+from litellm.utils import (
+ CustomStreamWrapper,
+ EmbeddingResponse,
+ ModelInfoLookupMiss,
+ ModelResponse,
+ ProviderConfigManager,
+ Rules,
+ TextCompletionResponse,
+ _restore_correlation_context_if_supported, # pyright: ignore[reportPrivateUsage] # same task-local cleanup as the SDK wrapper
+ aresolve_api_key_secret_reference,
+ function_setup,
+ get_llm_provider,
+ get_non_default_completion_params,
+ get_secret,
+ get_utc_datetime,
+ is_generalized_model_info,
+ is_region_allowed,
+ lookup_model_info_for_registration,
+ model_info_with_pricing_overrides,
+ provider_rejectable_params,
+ set_live_deployment_replay,
+ token_counter,
+)
+
+if TYPE_CHECKING:
+ from opentelemetry.trace import Span as _Span
+
+ from litellm.exceptions import MidStreamFallbackError
+ from litellm.responses.streaming_iterator import (
+ BaseResponsesAPIStreamingIterator,
+ )
+ from litellm.router_api import UntypedDict
+ from litellm.router_strategy.adaptive_router.adaptive_router import (
+ AdaptiveRouter,
+ )
+ from litellm.router_strategy.auto_router.auto_router import (
+ AutoRouter,
+ PreRoutingHookResponse,
+ )
+ from litellm.router_strategy.complexity_router.complexity_router import (
+ ComplexityRouter,
+ )
+ from litellm.router_strategy.quality_router.quality_router import (
+ QualityRouter,
+ )
+ from litellm.types.llms.anthropic_messages.anthropic_response import (
+ AnthropicMessagesResponse,
+ )
+ from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject
+ from litellm.types.llms.openai import (
+ ResponseAPIUsage,
+ ResponseIncompleteEvent,
+ ResponsesAPIResponse,
+ )
+
+ Span = _Span
+else:
+ Span = Any
+ AutoRouter = Any
+ ComplexityRouter = Any
+ AdaptiveRouter = Any
+ QualityRouter = Any
+ PreRoutingHookResponse = Any
+
+RouterStrategySelector: TypeAlias = (
+ LeastBusyLoggingHandler
+ | LowestCostLoggingHandler
+ | LowestLatencyLoggingHandler
+ | LowestTPMLoggingHandler
+ | LowestTPMLoggingHandler_v2
+)
+
+
+def _cost_value_as_float(value: str | float | None) -> float | None:
+ if value is None:
+ return None
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def model_info_is_active_for_environment(model_info: Mapping[str, object] | None) -> bool:
+ """Single owner of the environment-gating rule: a deployment whose model_info names
+ `supported_environments` loads only on pods whose LITELLM_ENVIRONMENT is in that list.
+ `Router.deployment_is_active_for_environment` delegates here, and the model-write
+ endpoints consult the same rule to tell a deliberately inactive model from one that
+ was dropped by a failed reload."""
+ if model_info is None:
+ return True
+ supported_environments: Final = model_info.get("supported_environments")
+ if supported_environments is None:
+ return True
+ if not isinstance(supported_environments, (list, tuple)):
+ raise ValueError(
+ f"supported_environments must be a list of {VALID_LITELLM_ENVIRONMENTS}. "
+ f"but set as: {supported_environments} for model_info: {model_info}"
+ )
+ litellm_environment: Final = get_secret_str(secret_name="LITELLM_ENVIRONMENT")
+ if litellm_environment is None:
+ raise ValueError("Set 'supported_environments' for model but not 'LITELLM_ENVIRONMENT' set in .env")
+
+ if litellm_environment not in VALID_LITELLM_ENVIRONMENTS:
+ raise ValueError(
+ f"LITELLM_ENVIRONMENT must be one of {VALID_LITELLM_ENVIRONMENTS}. but set as: {litellm_environment}"
+ )
+
+ for _env in supported_environments:
+ if _env not in VALID_LITELLM_ENVIRONMENTS:
+ raise ValueError(
+ f"supported_environments must be one of {VALID_LITELLM_ENVIRONMENTS}. but set as: {_env} "
+ f"for model_info: {model_info}"
+ )
+
+ if litellm_environment in supported_environments:
+ return True
+ return False
+
+
+_PreRoutingStrategyT = TypeVar("_PreRoutingStrategyT")
+_CallbackT = TypeVar("_CallbackT")
+_DeploymentT = TypeVar("_DeploymentT", bound=Mapping[str, object])
+
+
+_ALIAS_PARAMS_NEVER_FORWARDED: Final = frozenset(
+ {"model", "api_base", "api_key", "api_version", "responses_api_version"}
+)
+_ALIAS_MARKER_FORWARDED_PARAMS_KWARG: Final = "_alias_marker_forwarded_params"
+_FORWARDED_CREDENTIAL_ISOLATION_PROVIDERS: Final = frozenset({"chatgpt", "anthropic"})
+_CLAUDE_CODE_SESSION_ID_RE: Final = re.compile(r"^[a-zA-Z0-9_\-]{8,}$")
+_CLAUDE_CODE_SESSION_ROUTER_TTL_SECONDS: Final = 3600
+CLAUDE_CODE_SESSION_ROUTER_BINDING_TARGET: Final = "claude_code_session_router_binding"
+
+_PRE_CALL_CHECK_FILTERED_PARAMS: Final = frozenset({"response_format"})
+_STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS: Final = frozenset({"anthropic_messages", "aresponses", "responses"})
+_video_call_deployment_id: Final[ContextVar[str | None]] = ContextVar("video_call_deployment_id", default=None)
+_routed_call_type: Final[ContextVar[str | None]] = ContextVar("routed_call_type", default=None)
+_DECISIONS_MEDIA_CALL_TYPES: Final = frozenset({"adecisions", "decisions", "asystemone", "systemone"})
+_ROUTE_CAPABILITY_RESOLVERS: Final[Mapping[str, Callable[[str, LlmProviders], object | None]]] = MappingProxyType(
+ {
+ "adecisions": ProviderConfigManager.get_provider_decisions_config,
+ "decisions": ProviderConfigManager.get_provider_decisions_config,
+ }
+)
+_RUNTIME_TOGGLEABLE_PRE_CALL_CHECKS: Final[Mapping[str, type[CustomLogger]]] = MappingProxyType(
+ {
+ "prompt_caching": PromptCachingDeploymentCheck,
+ "enforce_model_rate_limits": ModelRateLimitingCheck,
+ "encrypted_content_affinity": EncryptedContentAffinityCheck,
+ }
+)
+
+
+def _stream_chunks_have_generated_content(chunks: Sequence[ModelResponseStream]) -> bool:
+ for chunk in chunks:
+ if not chunk.choices:
+ continue
+ delta = chunk.choices[0].delta
+ if (
+ delta.get("content")
+ or delta.get("tool_calls")
+ or delta.get("function_call")
+ or delta.get("reasoning_content")
+ or delta.get("thinking_blocks")
+ or delta.get("reasoning_items")
+ or delta.get("audio")
+ or delta.get("images")
+ or delta.get("annotations")
+ ):
+ return True
+ return False
+
+
+_NO_SESSION_KWARGS: Final[Mapping[str, Mapping[str, object]]] = MappingProxyType({})
+_NO_DEFAULT_PARAMS: Final[Mapping[str, object]] = MappingProxyType({})
+_SESSION_ADAPTER: Final = TypeAdapter(Mapping[str, object])
+_MODEL_INFO_ADAPTER: Final = TypeAdapter(Mapping[str, object])
+_LITELLM_PARAMS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
+_SILENT_MODEL_ADAPTER: Final = TypeAdapter(str | list[str])
+_RESOLVED_RETRY_POLICY_ADAPTER: Final = TypeAdapter(RetryPolicy | None)
+_ROUTING_KWARGS_ADAPTER: Final[TypeAdapter[Mapping[str, object] | None]] = TypeAdapter(Mapping[str, object] | None)
+_FALLBACK_HOP_ADAPTER: Final = TypeAdapter(Mapping[str, object])
+_REQUEST_KWARGS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
+_DEPLOYMENT_PARAMS_ADAPTER: Final[TypeAdapter[Mapping[str, object] | GenericLiteLLMParams]] = TypeAdapter(
+ Mapping[str, object] | GenericLiteLLMParams
+)
+_OPAQUE_VALUE_ADAPTER: Final = TypeAdapter(object)
+_DEPLOYMENT_SELECTED_EVENT: Final = "litellm.request.deployment_selected"
+_CONTENT_BLOCKS_ADAPTER: Final = TypeAdapter(list[dict[str, object]])
+_DEPLOYMENTS_ADAPTER: Final = TypeAdapter(Sequence[Mapping[str, object]])
+
+
+def _extend_trailing_text_block(blocks: Sequence[Mapping[str, object]], text: str) -> Sequence[Mapping[str, object]]:
+ """Continue the last text block in place so the prefill reads as the text the caller already received."""
+ last: Final = blocks[-1] if blocks else None
+ if last is not None and last.get("type") == "text" and isinstance(last_text := last.get("text"), str):
+ return [*blocks[:-1], {**last, "text": f"{last_text}{text}"}]
+ return [*blocks, {"type": "text", "text": text}]
+
+
+def _is_fallback_hop(request_kwargs: Mapping[str, object]) -> bool:
+ fallback_depth: Final = request_kwargs.get("fallback_depth")
+ return isinstance(fallback_depth, int) and fallback_depth > 0
+
+
+def _deployment_that_just_failed(request_metadata: object) -> str | None:
+ try:
+ model_info: Final = _FALLBACK_HOP_ADAPTER.validate_python(
+ _FALLBACK_HOP_ADAPTER.validate_python(request_metadata).get("model_info")
+ )
+ except ValidationError:
+ return None
+ model_id: Final = model_info.get("id")
+ return model_id if isinstance(model_id, str) else None
+
+
+def _drops_malformed_request_tools(
+ deployment_params: Mapping[str, object], defaults: Mapping[str, object], request: Mapping[str, object]
+) -> bool:
+ if isinstance(request.get("tools") or [], (list, tuple)) or not deployment_params.get("tools"):
+ return False
+ set_defaults: Final = {name: value for name, value in defaults.items() if value is not None}
+ return drop_policy({**deployment_params, **set_defaults, **request}).drops("tools")
+
+
+def _malformed_request_params_the_deployment_sets(
+ deployment_params: Mapping[str, object], defaults: Mapping[str, object], request: Mapping[str, object]
+) -> frozenset[str]:
+ overrides: Final = {
+ key: value
+ for key, value in request.items()
+ if key in SUPPLEMENTAL_KWARGS_KEYS and deployment_params.get(key) is not None
+ }
+ if not overrides:
+ return frozenset()
+ try:
+ validate_litellm_params_strictly(GenericLiteLLMParams, overrides)
+ except ValidationError as error:
+ set_defaults: Final = {name: value for name, value in defaults.items() if value is not None}
+ policy: Final = drop_policy({**deployment_params, **set_defaults, **request})
+ malformed: Final = (str(detail["loc"][0]) for detail in error.errors() if detail["loc"])
+ return frozenset(key for key in malformed if policy.drops(key))
+ return frozenset()
+
+
+def _drop_malformed_request_params_the_deployment_sets(
+ kwargs: dict[str, object], # mutable-ok: drops the keys from the router's request kwargs in place
+ deployment_params: Mapping[str, object],
+ defaults: Mapping[str, object],
+) -> None:
+ for key in _malformed_request_params_the_deployment_sets(deployment_params, defaults, kwargs):
+ kwargs.pop(key)
+
+
+def _merged_tools(deployment_tools: Sequence[object], request_tools: object) -> list[object] | None:
+ if not isinstance(request_tools, (list, tuple)):
+ return None
+ return [*deployment_tools, *request_tools] if deployment_tools or request_tools else None
+
+
+def _deployment_pick_attributes(model: str, request_kwargs: Mapping[str, object] | None) -> Mapping[str, str | int]:
+ """Bounded attributes for one deployment pick; attempt is 1-based within the current model group."""
+ kwargs: Final = request_kwargs or {}
+ metadata: Final = kwargs.get("litellm_metadata", kwargs.get("metadata"))
+ attempted_retries: Final = metadata.get("attempted_retries") if isinstance(metadata, Mapping) else None
+ retries: Final = attempted_retries if isinstance(attempted_retries, int) else 0
+ reason: Final = "retry" if retries > 0 else "fallback" if _is_fallback_hop(kwargs) else "initial"
+ return MappingProxyType(
+ {
+ "litellm.deployment.attempt": retries + 1,
+ "litellm.deployment.reason": reason,
+ "litellm.deployment.model_group": model,
+ }
+ )
+
+
+def _configured_model_info(deployment: Mapping[str, object]) -> Mapping[str, object]:
+ return _MODEL_INFO_ADAPTER.validate_python(deployment.get("model_info") or {})
+
+
+def _search_tool_support(deployment: DeploymentTypedDict) -> bool | None:
+ from litellm.responses.litellm_completion_transformation.tool_search import chat_config_builds_anthropic_request
+
+ configured: Final = _configured_model_info(deployment).get("supports_search_tool")
+ if isinstance(configured, bool):
+ return configured
+ params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(deployment["litellm_params"])
+ model: Final = params.get("model")
+ provider: Final = params.get("custom_llm_provider")
+ if not isinstance(model, str):
+ return None
+ try:
+ resolved_model, resolved_provider = resolve_model_and_provider_for_metadata(
+ model=model, custom_llm_provider=provider if isinstance(provider, str) else None
+ )
+ chat_config: Final = ProviderConfigManager.get_provider_chat_config(
+ model=resolved_model, provider=LlmProviders(resolved_provider)
+ )
+ except (litellm_exceptions.BadRequestError, ValueError):
+ return None
+ return True if chat_config_builds_anthropic_request(chat_config) else None
+
+
+def _every_deployment_refused(
+ deployments: Sequence[Mapping[str, object]], refused_deployment_ids: tuple[str, ...]
+) -> bool:
+ return all(_configured_model_info(deployment).get("id") in refused_deployment_ids for deployment in deployments)
+
+
+def _silent_experiment_targets(silent_model: object) -> tuple[str, ...]:
+ if silent_model is None or in_mirrored_call():
+ return ()
+ try:
+ targets: Final = _SILENT_MODEL_ADAPTER.validate_python(silent_model)
+ except ValidationError:
+ verbose_router_logger.warning(
+ "silent_model must be a model name or a list of model names, got %r; skipping shadow traffic",
+ silent_model,
+ )
+ return ()
+ return (targets,) if isinstance(targets, str) else tuple(targets)
+
+
+def _silent_experiment_kwargs_snapshot(kwargs: Mapping[str, object]) -> Mapping[str, object]:
+ metadata: Final = kwargs.get("metadata")
+ if not isinstance(metadata, Mapping):
+ return MappingProxyType({**kwargs})
+ return MappingProxyType({**kwargs, "metadata": dict(metadata)})
+
+
+def _with_router_resolved_session_model(session: object, model_name: str) -> Mapping[str, Mapping[str, object]]:
+ """
+ Realtime client-secret requests carry the model inside ``session`` as well, and the caller's copy of it still
+ holds the pre-routing model group name, so it has to follow the deployment the router just picked.
+
+ Returns kwargs to merge into the downstream call, empty when there is no session model to resolve.
+ """
+ try:
+ typed_session: Final = _SESSION_ADAPTER.validate_python(session)
+ except ValidationError:
+ return _NO_SESSION_KWARGS
+ if "model" not in typed_session:
+ return _NO_SESSION_KWARGS
+ return MappingProxyType({"session": {**typed_session, "model": model_name}})
+
+
+# Router._aanthropic_messages_streaming_iterator buffers lifecycle chunks
+# until real content commits the primary stream, and only while a fallback
+# can still take over; a hostile or slow-starting upstream that never emits
+# content or an error could otherwise grow that buffer without bound, so
+# hitting this cap forces an early commit instead.
+MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS: Final = 200
+
+
+def _anthropic_stream_forwards_ping_live(chunk: object, has_generated_content: bool) -> bool:
+ """A `ping` keepalive reaches the client live whenever the stream has not committed: it carries no
+ lifecycle, so it cannot create overlapping lifecycles on the wire, and it keeps the connection alive
+ while lifecycle frames sit buffered for a possible fallback during a long thinking pass."""
+ from litellm.llms.anthropic.messages.native.streaming_iterator import is_anthropic_ping_chunk
+
+ return not has_generated_content and is_anthropic_ping_chunk(chunk)
+
+
+def _is_retriable_anthropic_status(status_code: int) -> bool:
+ return status_code == 429 or status_code >= 500
+
+
+def _without_line_breaks(value: object) -> str:
+ return str(value).replace("\r", "").replace("\n", "")
+
+
+def _anthropic_stream_error_is_gateway_verdict(chunk: object) -> bool:
+ """AgenticAnthropicStreamingIterator's own retrieval-failure frame is the gateway's verdict, not a provider
+ failure: another deployment would rerun the same failed hook, so it reaches the client instead of falling back."""
+ from litellm.llms.anthropic.messages.native.agentic_streaming_iterator import (
+ is_server_fulfilled_tool_leak_error,
+ )
+
+ return is_server_fulfilled_tool_leak_error(chunk)
+
+
+def _anthropic_stream_should_decline_fallback(has_generated_content: bool, error: "MidStreamFallbackError") -> bool:
+ """
+ A MidStreamFallbackError raised directly by the source iterator (the
+ completion-bridge path's CustomStreamWrapper, e.g. on a transport drop)
+ carries its own pre_first_chunk bookkeeping - gated the same way a
+ detected SSE error event is, so a fallback is never appended after real
+ content already reached the client on either path.
+ """
+ return has_generated_content or not error.is_pre_first_chunk
+
+
+def _stream_raised_error_status(error: Exception) -> int | None:
+ raw_status: Final = getattr(error, "status_code", None)
+ if isinstance(raw_status, int):
+ return raw_status
+ if isinstance(raw_status, str) and raw_status.isdigit():
+ return int(raw_status)
+ response_status: Final = getattr(getattr(error, "response", None), "status_code", None)
+ return response_status if isinstance(response_status, int) else None
+
+
+def _stream_fallback_error_for_raised(
+ error: Exception, model: str, has_generated_content: bool, llm_provider: str = "anthropic"
+) -> "MidStreamFallbackError | None":
+ """The pre-stream retry rule (408, 409, 429, 5xx); None means the raise propagates unchanged."""
+ if has_generated_content:
+ return None
+ status_code: Final = _stream_raised_error_status(error)
+ if status_code is None:
+ return _stream_pre_content_error(error, model, llm_provider)
+ retriable: Final = python_host.invoke("should_retry")(status_code)
+ return _stream_pre_content_error(error, model, llm_provider) if retriable else None
+
+
+def _stream_pre_content_error(
+ error: Exception, model: str, llm_provider: str = "anthropic"
+) -> "MidStreamFallbackError":
+ """The envelope the fallback chain judges a failure by when the client has received no content yet."""
+ from litellm.exceptions import MidStreamFallbackError
+
+ return MidStreamFallbackError(
+ message=str(error),
+ model=model,
+ llm_provider=llm_provider,
+ original_exception=error,
+ is_pre_first_chunk=True,
+ )
+
+
+def _context_window_stream_fallback_error(
+ error: "MidStreamFallbackError | litellm_exceptions.ContextWindowExceededError",
+ model: object,
+ llm_provider: str,
+ has_output: bool,
+) -> "MidStreamFallbackError":
+ if isinstance(error, litellm_exceptions.ContextWindowExceededError):
+ if has_output:
+ raise error
+ return _stream_pre_content_error(error, model if isinstance(model, str) else "", llm_provider)
+ if isinstance(error.original_exception, litellm_exceptions.ContextWindowExceededError) and (
+ has_output or error.generated_content or error.emitted_disqualifying_content
+ ):
+ raise error.original_exception
+ return error
+
+
+def _deployment_num_retries(deployment: "Deployment | None") -> int | None:
+ """The deployment's own num_retries litellm_param, an int or a digit string the way the config loader leaves it."""
+ configured: Final = getattr(deployment.litellm_params, "num_retries", None) if deployment is not None else None
+ if isinstance(configured, bool) or not isinstance(configured, (int, str)):
+ return None
+ return int(configured) if str(configured).isdigit() else None
+
+
+def _request_fallback_list(
+ kwargs: Mapping[str, object], key: str, router_default: "list[object] | None"
+) -> "list[object] | None": # mutable-ok: should_retry_this_error's own parameter type
+ return cast("list[object] | None", kwargs.get(key, router_default)) # cast-ok: same type as the router attribute
+
+
+def _request_model_group(kwargs: Mapping[str, object]) -> str | None:
+ model_group: Final = kwargs.get("model")
+ return model_group if isinstance(model_group, str) else None
+
+
+def _mid_stream_error_before_output(error: "MidStreamFallbackError") -> bool:
+ return not error.generated_content or error.is_pre_first_chunk
+
+
+def _mid_stream_retry_trigger(error: "MidStreamFallbackError") -> Exception:
+ """The provider's own error, which is what the retry policy and should_retry_this_error classify."""
+ return error.original_exception if error.original_exception is not None else error
+
+
+_RestT: Final = TypeVar("_RestT", Iterator[object], AsyncIterator[object])
+_RouteDeploymentsT: Final = TypeVar("_RouteDeploymentsT", bound=Sequence[Mapping[str, object]] | Mapping[str, object])
+
+
+@dataclass(frozen=True, slots=True)
+class _FirstChunk(Generic[_RestT]):
+ """
+ The chunk Router._acompletion / _completion already read off a stream (None: the
+ provider closed it empty) and the iterator it was read from, which carries on after it.
+ """
+
+ chunk: ModelResponseStream | None
+ rest: _RestT
+
+
+async def _aread_first_chunk(stream: CustomStreamWrapper) -> _FirstChunk[AsyncIterator[object]]:
+ """
+ Read the first chunk while Router._acompletion still holds the request, so a
+ stream that fails before any token raises there, inside async_function_with_retries
+ and the fallback chain, instead of surfacing to the caller mid-iteration.
+
+ Iterates the way `async for` does, one __aiter__ call and then __anext__ on what it
+ returned: a CustomStreamWrapper subclass may hand iteration to another object and
+ leave completion_stream None (the MCP gateway's MCPStreamWrapper).
+ """
+ from litellm.exceptions import MidStreamFallbackError
+
+ rest: Final = stream.__aiter__()
+ try:
+ return _FirstChunk(chunk=await rest.__anext__(), rest=rest)
+ except StopAsyncIteration:
+ return _FirstChunk(chunk=None, rest=rest)
+ except MidStreamFallbackError as e:
+ raise _mid_stream_retry_trigger(e) from e
+
+
+def _read_first_chunk(stream: CustomStreamWrapper) -> _FirstChunk[Iterator[object]]:
+ """Sync twin of _aread_first_chunk for Router._completion."""
+ from litellm.exceptions import MidStreamFallbackError
+
+ rest: Final = iter(stream)
+ try:
+ return _FirstChunk(chunk=next(rest), rest=rest)
+ except StopIteration:
+ return _FirstChunk(chunk=None, rest=rest)
+ except MidStreamFallbackError as e:
+ raise _mid_stream_retry_trigger(e) from e
+
+
+def _chunks_with_first(
+ model_response: Iterable[object], taken: _FirstChunk[Iterator[object]] | None
+) -> Iterator[object]:
+ """The stream with the chunk _read_first_chunk already read put back in front (None: the stream as is)."""
+ if taken is None:
+ yield from model_response
+ return
+ if taken.chunk is None:
+ return
+ yield taken.chunk
+ yield from taken.rest
+
+
+async def _achunks_with_first(
+ model_response: AsyncIterable[object], taken: _FirstChunk[AsyncIterator[object]] | None
+) -> AsyncIterator[object]:
+ """Async twin of _chunks_with_first."""
+ if taken is None:
+ async for item in model_response:
+ yield item
+ return
+ if taken.chunk is None:
+ return
+ yield taken.chunk
+ async for item in taken.rest:
+ yield item
+
+
+def _stream_commits_now(chunk: object, has_generated_content: bool, buffered_chunk_count: int) -> bool:
+ """
+ Whether `chunk` should make Router._aanthropic_messages_streaming_iterator
+ commit to the primary Anthropic stream (real content arrived, or the
+ pre-content buffer cap was hit) rather than keep buffering lifecycle
+ frames toward a possible fallback.
+ """
+ from litellm.llms.anthropic.messages.native.streaming_iterator import (
+ is_anthropic_content_delta_chunk,
+ )
+
+ if has_generated_content:
+ return False
+ return is_anthropic_content_delta_chunk(chunk) or buffered_chunk_count >= MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS
+
+
+@dataclass(frozen=True, slots=True)
+class _StreamRetryOpened:
+ response: object
+ attempted_retries: int
+ max_retries: int
+
+
+@dataclass(frozen=True, slots=True)
+class _StreamRetriesExhausted:
+ error: "MidStreamFallbackError"
+
+
+_StreamRetryOutcome: TypeAlias = _StreamRetryOpened | _StreamRetriesExhausted
+
+
+MAX_HELD_PRE_OUTPUT_RESPONSES_EVENTS: Final = 200
+
+
+def _retry_policy_ceiling(policy: RetryPolicy) -> int:
+ """The most retries any error class under this policy can be granted."""
+ retry_counts: Final[tuple[object, ...]] = tuple(policy.model_dump().values())
+ return max((retries for retries in retry_counts if isinstance(retries, int)), default=0)
+
+
+def _responses_stream_holds_event(item: object, held_event_count: int) -> bool:
+ from litellm.responses.streaming_iterator import PRE_OUTPUT_LIFECYCLE_EVENT_TYPES
+
+ if held_event_count >= MAX_HELD_PRE_OUTPUT_RESPONSES_EVENTS:
+ return False
+ return getattr(item, "type", None) in PRE_OUTPUT_LIFECYCLE_EVENT_TYPES
+
+
+def _responses_event_announces_background_run(item: object) -> bool:
+ return getattr(getattr(item, "response", None), "background", None) is True
+
+
+class FallbackAwareAnthropicMessagesStream:
+ """
+ Bare async generators can't carry the `_hidden_params` attribute the
+ proxy reads response headers off of (see
+ router_utils.add_retry_fallback_headers.get_hidden_params_dict), so this
+ thin wrapper carries it through from the source iterator - mirrors
+ AnthropicMessagesStreamingResponse. Used by
+ Router._aanthropic_messages_streaming_iterator.
+ """
+
+ def __init__(self, async_generator: AsyncGenerator[bytes, None], source_iterator: object) -> None:
+ self._async_generator = async_generator
+ self._source_iterator = source_iterator
+ self.fallback_headers_adopted = False
+ self._hidden_params = dict(getattr(source_iterator, "_hidden_params", None) or {})
+ self._followed_source_params: object = None
+
+ @property
+ def has_buffered_provider_output(self) -> bool:
+ return getattr(self._source_iterator, "has_buffered_provider_output", False) is True
+
+ @property
+ def chunks(self) -> list[ModelResponseStream] | None:
+ return cast( # cast-ok: chunks is a list of ModelResponseStream on the inner stream
+ "list[ModelResponseStream] | None", getattr(self._source_iterator, "chunks", None)
+ )
+
+ @property
+ def messages(self) -> list[AllMessageValues] | None:
+ return cast( # cast-ok: messages is a list of AllMessageValues on the inner stream
+ "list[AllMessageValues] | None", getattr(self._source_iterator, "messages", None)
+ )
+
+ @property
+ def model(self) -> str | None:
+ return cast( # cast-ok: model is a str on the inner stream
+ "str | None", getattr(self._source_iterator, "model", None)
+ )
+
+ def adopt_fallback_source(self, fallback_response: object) -> None:
+ self._source_iterator = fallback_response
+ self.fallback_headers_adopted = True
+
+ def partial_response_on_disconnect(self) -> "ResponseIncompleteEvent | None":
+ from litellm.responses.streaming_iterator import partial_responses_incomplete_event_on_disconnect
+
+ return partial_responses_incomplete_event_on_disconnect(self._source_iterator)
+
+ def follow_source_attribution(self) -> None:
+ """
+ A retry's or a fallback's stream carries a wrapper of its own, so a hop it makes before its
+ first byte lands on that wrapper while the proxy reads the headers off this one. Mirrors the
+ source's current attribution onto this wrapper whenever the source has adopted a new one.
+ """
+ source: Final = self._source_iterator
+ if not getattr(source, "fallback_headers_adopted", False):
+ return
+ source_params: Final = getattr(source, "_hidden_params", None)
+ if source_params is None or source_params is self._followed_source_params:
+ return
+ self._followed_source_params = source_params
+ hidden_params, headers = PythonCore._prepare_fallback_hidden_params(source) # pyright: ignore[reportPrivateUsage] # this wrapper is the Router's own stream type
+ self.merge_fallback_hidden_params(hidden_params, headers)
+
+ def __aiter__(self) -> "FallbackAwareAnthropicMessagesStream":
+ return self
+
+ async def __anext__(self) -> bytes:
+ return await self._async_generator.__anext__()
+
+ async def aclose(self) -> None:
+ await self._async_generator.aclose()
+
+ def merge_fallback_hidden_params(
+ self,
+ fallback_hidden_params: Mapping[str, object],
+ fallback_headers: Mapping[str, object],
+ ) -> None:
+ """
+ Raw bytes can't carry their own _hidden_params the way a
+ ModelResponseStream/ResponsesAPI event can, so a mid-stream
+ fallback's provider headers (e.g. Bedrock's x-amzn-requestid) are
+ merged onto the wrapper itself instead - mirrors
+ Router._apply_fallback_hidden_params_to_item's merge shape.
+ """
+ existing_headers: Final = cast( # cast-ok: additional_headers is always a dict[str, object] when present
+ "dict[str, object]", self._hidden_params.get("additional_headers") or {}
+ )
+ self._hidden_params = {
+ **self._hidden_params,
+ **fallback_hidden_params,
+ "additional_headers": dict(replace_complexity_router_headers(existing_headers, fallback_headers)),
+ }
+
+
+class RoutingArgs(enum.Enum):
+ ttl = 60 # 1min (RPM/TPM expire key)
+
+
+# Routers that are still in use, so a price data reload can rebuild the cost-map
+# entries their deployments own. Weak so a router nothing references any more, such
+# as the per-request one built from a caller-supplied user_config, drops out on its
+# own rather than leaving entries behind that nothing can withdraw.
+_live_routers: Final["weakref.WeakSet[PythonCore]"] = weakref.WeakSet()
+_routers_holding_global_callbacks: Final["weakref.WeakSet[PythonCore]"] = weakref.WeakSet()
+_routers_holding_global_callbacks_lock: Final = threading.Lock()
+
+
+def _replay_live_router_model_cost() -> None:
+ """Re-assert every live router's deployments after the cost map is refreshed."""
+ for router in tuple(_live_routers):
+ router.replay_model_cost_registrations()
+
+
+set_live_deployment_replay(_replay_live_router_model_cost)
+
+
+RETRY_BREADCRUMB_LIMIT: Final = 4
+
+
+class FallbackAwareStreamWrapper(CustomStreamWrapper):
+ """Base for the Router's chat-completion stream wrappers, which are built around the
+ attempt the Router picked first and have to repoint themselves when a fallback takes over."""
+
+ fallback_headers_adopted: bool = False
+
+ def adopt_fallback_response_headers(
+ self,
+ fallback_response: object,
+ prepared_fallback_hidden_params: tuple[dict[str, object], dict[str, object]],
+ ) -> None:
+ """Repoint this wrapper at the deployment that served the stream.
+
+ Replaces rather than merges, so the failed attempt's `x-request-id`, rate limit
+ counters, `model_id` and `api_base` cannot reach the proxy's response headers or
+ its callbacks.
+ """
+ self._response_headers = getattr(fallback_response, "_response_headers", None)
+ fallback_hidden_params, fallback_headers = prepared_fallback_hidden_params
+ if fallback_hidden_params:
+ self._hidden_params = {
+ **fallback_hidden_params,
+ # dict() because add_retry_fallback_headers mutates additional_headers in place
+ "additional_headers": dict(fallback_headers),
+ }
+ self._base_hidden_params = {
+ **self._hidden_params,
+ "response_cost": None,
+ }
+ self.fallback_headers_adopted = True
+
+
+def as_output_cap(value: object) -> int | None:
+ """A client-sent output cap coerced to an int: ints, floats and numeric strings, never bools
+ or negatives."""
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
+ return None
+ try:
+ cap: Final = int(float(value))
+ except (ValueError, OverflowError):
+ return None
+ return cap if cap >= 0 else None
+
+
+def stamped_client_ceiling(request_kwargs: Mapping[str, object]) -> Mapping[str, int] | None:
+ """None means unstamped; an empty mapping means the original caller supplied no limit."""
+ stamped: Final = next(
+ (
+ bucket.get(CLIENT_OUTPUT_CEILING_METADATA_KEY)
+ for bucket in (request_kwargs.get("metadata"), request_kwargs.get("litellm_metadata"))
+ if isinstance(bucket, dict) and CLIENT_OUTPUT_CEILING_METADATA_KEY in bucket
+ ),
+ None,
+ )
+ if not isinstance(stamped, dict):
+ return None
+ return MappingProxyType(
+ {
+ carrier: cap
+ for carrier, value in stamped.items()
+ if carrier in OUTPUT_TOKEN_CEILING_PARAMS and (cap := as_output_cap(value)) is not None
+ }
+ )
+
+
+def _exact_base_model_max_input_tokens(base_model: str) -> int | None:
+ try:
+ model_info: Final = python_host.invoke("get_model_info")(model=base_model)
+ except Exception: # noqa: BLE001 # get_model_info raises ModelNotMappedError or a bare Exception for an unmapped model
+ return None
+ if is_generalized_model_info(model_info):
+ return None
+ return model_info.get("max_input_tokens")
+
+
+def _provider_model_id(model_name: str, custom_llm_provider: str) -> str:
+ model_id: Final = model_name.rsplit("/", 1)[-1]
+ return get_bedrock_base_model(model_id) if custom_llm_provider.startswith("bedrock") else model_id
+
+
+def _names_route_model(base_model: str, route_key: str, custom_llm_provider: str) -> bool:
+ route_model_id: Final = _provider_model_id(route_key, custom_llm_provider)
+ base_model_id: Final = _provider_model_id(base_model, custom_llm_provider)
+ return route_model_id in (base_model_id, base_model_id.split("@", 1)[0])
+
+
+def _declared_base_model(user_model_info: Mapping[str, object]) -> str | None:
+ match user_model_info:
+ case {"base_model": str() as base_model}:
+ return base_model
+ case _:
+ return None
+
+
+def _base_model_widened_window(
+ route_model_info: ModelMapInfo, base_model: str | None, custom_llm_provider: str
+) -> Mapping[str, int]:
+ route_max_input_tokens: Final = route_model_info.get("max_input_tokens")
+ if base_model is None or route_max_input_tokens is None:
+ return MappingProxyType({})
+ if not _names_route_model(base_model, route_model_info["key"], custom_llm_provider):
+ return MappingProxyType({})
+ base_max_input_tokens: Final = _exact_base_model_max_input_tokens(base_model)
+ if base_max_input_tokens is None or base_max_input_tokens <= route_max_input_tokens:
+ return MappingProxyType({})
+ return MappingProxyType({"max_input_tokens": base_max_input_tokens})
+
+
+_SETTING_ATTRIBUTES: Final = frozenset("_core_" + name for name in SETTING_FIELDS)
+
+
+class PythonCore(DeprecatedRouterMembers):
+ _core_model_names: set[str] = set()
+ _core_cache_responses: bool | None = False
+ _registry_version: int = 0
+ # Bumped by every ``apply``: the static arguments plus ``_core_applied`` are the settings.
+ _settings_version: int = 0
+ _core_applied: Mapping[str, object] = MappingProxyType({})
+
+ # -- entry points: everything outside code needs besides the endpoint methods ---------------
+ # See litellm/router_api.py. The members outside code used before these live in
+ # DeprecatedRouterMembers (litellm/router_deprecated.py).
+
+ def registry(self) -> RouterRegistry:
+ """Deployment lookups: by id, group, alias, wildcard and team, plus configured plugins."""
+ cached: Final = self.__dict__.get("_registry_view")
+ if isinstance(cached, RouterRegistry):
+ return cached
+ view: Final = RouterRegistry(self)
+ self.__dict__["_registry_view"] = view
+ return view
+
+ def settings(self) -> RouterSettings:
+ """
+ A frozen snapshot of the router's configuration. It is built once and reused until a
+ setting attribute is rebound or deleted (``__setattr__``/``__delattr__`` drop it), so the
+ request path can read it cheaply.
+ """
+ cached: Final = self.__dict__.get("_settings_snapshot")
+ if isinstance(cached, RouterSettings):
+ return cached
+ snapshot: Final = RouterSettings.of(self)
+ self.__dict__["_settings_snapshot"] = snapshot
+ return snapshot
+
+ def __setattr__(self, name: str, value: object) -> None:
+ if (
+ (name in PLAIN_SETTING_ATTRIBUTES or name in _SETTING_ATTRIBUTES)
+ and self.__dict__.get("_core_ready")
+ and not self.__dict__.get("_core_applying")
+ ):
+ # Settings change only through a changeset, so every core serving this router sees it.
+ self.apply(ChangeSet(assign={name.removeprefix("_core_"): value}))
+ return
+ if name in _SETTING_ATTRIBUTES:
+ self.__dict__.pop("_settings_snapshot", None)
+ elif name == "_core_model_list" and "_core_model_list" in self.__dict__:
+ # A whole new deployment list (``router.model_list = ...``) is a deployment change too.
+ self.__dict__["_registry_version"] = self._registry_version + 1
+ super().__setattr__(name, value)
+
+ def __delattr__(self, name: str) -> None:
+ if name in _SETTING_ATTRIBUTES:
+ self.__dict__.pop("_settings_snapshot", None)
+ super().__delattr__(name)
+
+ def health(self) -> RouterHealth:
+ """Cooldowns, blocked deployments, health checks and usage."""
+ cached: Final = self.__dict__.get("_health_view")
+ if isinstance(cached, RouterHealth):
+ return cached
+ view: Final = RouterHealth(self)
+ self.__dict__["_health_view"] = view
+ return view
+
+ @overload
+ async def select(
+ self,
+ model: str,
+ request_kwargs: dict[str, object] | None = None, # mutable-ok: the selection cores take a dict
+ *,
+ kind: Literal["pick", "pass_through"] = "pick",
+ messages: list[dict[str, str]] | None = None, # mutable-ok: the selection cores take a list
+ input: str | list[object] | None = None, # mutable-ok: the selection cores take a list
+ specific_deployment: bool | None = False,
+ parent_otel_span: Span | None = None,
+ health_check_probe: bool = False,
+ ) -> "UntypedDict": ... # a mutable dict, as before: the cores' untyped deployment dict, unchanged
+
+ @overload
+ async def select(
+ self,
+ model: str,
+ request_kwargs: dict[str, object] | None = None, # mutable-ok: the selection cores take a dict
+ *,
+ kind: Literal["healthy"],
+ messages: list[dict[str, str]] | None = None, # mutable-ok: the selection cores take a list
+ input: str | list[object] | None = None, # mutable-ok: the selection cores take a list
+ specific_deployment: bool | None = False,
+ parent_otel_span: Span | None = None,
+ health_check_probe: bool = False,
+ ) -> "list[UntypedDict] | UntypedDict": ... # mutable-ok: the core's type (list[dict] | dict), unchanged
+
+ async def select( # pyright: ignore[reportUnknownParameterType] # the selection cores return untyped deployment dicts
+ self,
+ model: str,
+ request_kwargs: dict[str, object] | None = None, # mutable-ok: the selection cores take a dict
+ *,
+ kind: SelectKind = "pick",
+ messages: list[dict[str, str]] | None = None, # mutable-ok: the selection cores take a list
+ input: str | list[object] | None = None, # mutable-ok: the selection cores take a list
+ specific_deployment: bool | None = False,
+ parent_otel_span: Span | None = None,
+ health_check_probe: bool = False,
+ ):
+ """
+ Pick a deployment for ``model`` without calling it (``pick``), list the deployments that
+ are healthy for the request (``healthy``), or pick one for a pass-through route
+ (``pass_through``).
+ """
+ if kind == "pick":
+ return await self._core_async_get_available_deployment( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the selection cores return untyped deployment dicts
+ model=model,
+ request_kwargs=request_kwargs if request_kwargs is not None else {},
+ messages=messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ )
+ if kind == "healthy":
+ return await self._core_async_get_healthy_deployments( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the selection cores return untyped deployment dicts
+ model=model,
+ request_kwargs=request_kwargs if request_kwargs is not None else {},
+ messages=messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ parent_otel_span=parent_otel_span,
+ health_check_probe=health_check_probe,
+ )
+ if kind == "pass_through":
+ return self._core_get_available_deployment_for_pass_through( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the selection cores return untyped deployment dicts
+ model=model,
+ messages=messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ request_kwargs=request_kwargs,
+ )
+ raise ValueError(f"unknown select kind {kind!r}")
+
+ def apply(self, changes: ChangeSet) -> ApplyResult:
+ """Apply runtime changes: settings, deployments, the routing state's Redis cache."""
+ with self._core_applying_changes(changes):
+ return apply_changes(self, changes)
+
+ async def aapply(self, changes: ChangeSet) -> ApplyResult:
+ """``apply``, plus the changes that need I/O (``refresh_model_info``)."""
+ with self._core_applying_changes(changes):
+ return await aapply_changes(self, changes)
+
+ @contextlib.contextmanager
+ def _core_applying_changes(self, changes: ChangeSet) -> Generator[None, None, None]:
+ """Mark a changeset in progress, then record its settings and bump ``_settings_version``."""
+ depth: Final = self.__dict__.get("_core_applying", 0)
+ self.__dict__["_core_applying"] = depth + 1
+ try:
+ yield
+ finally:
+ self.__dict__["_core_applying"] = depth
+ if changes.assign or changes.settings:
+ self.__dict__["_core_applied"] = MappingProxyType(
+ {**self._core_applied, **changes.assign, **changes.settings}
+ )
+ self.__dict__["_settings_version"] = self._settings_version + 1
+
+ def observe(self, logging_obj: "LiteLLMLogging | None" = None, *, request_scoped: bool = False) -> RoutingObservers:
+ """
+ The router's routing observers. ``request_scoped=True`` stops registering them globally,
+ so each request binds them instead; passing a request's ``logging_obj`` binds them to it.
+ """
+ if request_scoped:
+ self._core_serve_through_request_logging()
+ if logging_obj is not None:
+ self._core_bind_to_request_logging(logging_obj)
+ return RoutingObservers(slack_alerting_logger=getattr(self, "_core_slack_alerting_logger", None))
+
+ def register_plugin(self, plugin: CustomRoutingStrategyBase) -> None:
+ """Route with a custom strategy instead of the configured one."""
+ self._core_set_custom_routing_strategy(plugin)
+
+ def retire(self) -> None:
+ """Release the router's global callback registrations and routing state."""
+ self._core_discard()
+
+ def _core_public(self) -> "PythonCore":
+ """The ``litellm.Router`` this core serves, for objects the router hands out; the core itself if none."""
+ owner: Final = self.__dict__.get("_core_owner")
+ public: Final = owner() if owner is not None else None
+ return self if public is None else public
+
+ async def call(self, op: str, request: Mapping[str, object]) -> object:
+ """Run one endpoint (``acompletion``, ``aembedding``, ...) with the request's keyword payload."""
+ result: Final = getattr(self, op)(**request)
+ return await result if inspect.isawaitable(result) else result
+
+ default_cache_time_seconds: int = 1 * 60 * 60 # 1 hour
+ tenacity = None
+ leastbusy_logger: LeastBusyLoggingHandler | None = None
+ lowesttpm_logger: LowestTPMLoggingHandler | None = None
+ optional_callbacks: list[CustomLogger | Callable | str] | None = None
+
+ def __init__(
+ self,
+ model_list: list[DeploymentTypedDict] | list[dict[str, Any]] | None = None,
+ ## ASSISTANTS API ##
+ assistants_config: AssistantsTypedDict | None = None,
+ ## SEARCH API ##
+ search_tools: list[SearchToolTypedDict] | None = None,
+ ## GUARDRAIL API ##
+ guardrail_list: list[GuardrailTypedDict] | None = None,
+ ## CACHING ##
+ redis_url: str | None = None,
+ redis_host: str | None = None,
+ redis_port: int | None = None,
+ redis_password: str | None = None,
+ redis_db: int | None = None,
+ cache_responses: bool | None = False,
+ cache_kwargs: dict = {}, # additional kwargs to pass to RedisCache (see caching.py)
+ caching_groups: list[tuple] | None = None, # if you want to cache across model groups
+ client_ttl: int = 3600, # ttl for cached clients - will re-initialize after this time in seconds
+ ## SCHEDULER ##
+ polling_interval: float | None = None,
+ default_priority: int | None = None,
+ ## RELIABILITY ##
+ num_retries: int | None = None,
+ max_fallbacks: int | None = None, # max fallbacks to try before exiting the call. Defaults to 5.
+ timeout: float | None = None,
+ stream_timeout: float | None = None,
+ default_litellm_params: dict | None = None, # default params for Router.chat.completion.create
+ default_max_parallel_requests: int | None = None,
+ set_verbose: bool = False,
+ debug_level: Literal["DEBUG", "INFO"] = "INFO",
+ default_fallbacks: list[str] | None = None, # generic fallbacks, works across all deployments
+ fallbacks: list = [],
+ context_window_fallbacks: list = [],
+ content_policy_fallbacks: list = [],
+ model_group_alias: dict[str, str | RouterModelGroupAliasItem] | None = {},
+ enable_pre_call_checks: bool = False,
+ enable_tag_filtering: bool = False,
+ tag_filtering_match_any: bool = True,
+ tag_routing_prefix: str = "",
+ plugins: list[RoutingPlugin] | None = None,
+ retry_after: int = 0, # min time to wait before retrying a failed request
+ retry_policy: RetryPolicy | dict | None = None, # set custom retries for different exceptions
+ model_group_retry_policy: dict[str, RetryPolicy] = {}, # set custom retry policies based on model group
+ allowed_fails: int | None = None, # Number of times a deployment can failbefore being added to cooldown
+ allowed_fails_policy: AllowedFailsPolicy | None = None, # set custom allowed fails policy
+ cooldown_time: float | None = None, # (seconds) time to cooldown a deployment after failure
+ disable_cooldowns: bool | None = None,
+ fallback_on_unsupported_params: bool = True,
+ routing_strategy: RoutingStrategyName = "simple-shuffle",
+ optional_pre_call_checks: OptionalPreCallChecks | None = None,
+ routing_strategy_args: dict = {}, # just for latency-based
+ routing_groups: list[RoutingGroup | dict] | None = None,
+ provider_budget_config: GenericBudgetConfigType | None = None,
+ alerting_config: AlertingConfig | None = None,
+ router_general_settings: RouterGeneralSettings | None = RouterGeneralSettings(),
+ deployment_affinity_ttl_seconds: int = 3600,
+ model_group_affinity_config: dict[str, list[str]] | None = None,
+ ignore_invalid_deployments: bool = False,
+ enable_health_check_routing: bool = False,
+ health_check_staleness_threshold: int | None = None,
+ health_check_ignore_transient_errors: bool = False,
+ background_health_check_model_groups: Sequence[str] | None = None,
+ enable_weighted_failover: bool = False,
+ enable_mid_stream_fallback_continuation: bool = False,
+ fallback_access_check: FallbackAccessCheck | None = None,
+ fallback_budget_check: FallbackBudgetCheck | None = None,
+ auto_router_capability_limit: AutoRouterCapabilityLimit | None = None,
+ session_affinity_repin_on_failover: bool = False,
+ _host: PythonHost = python_host,
+ ) -> None:
+ """
+ Initialize the Router class with the given parameters for caching, reliability, and routing strategy.
+
+ Args:
+ model_list (Optional[list]): List of models to be used. Defaults to None.
+ redis_url (Optional[str]): URL of the Redis server. Defaults to None.
+ redis_host (Optional[str]): Hostname of the Redis server. Defaults to None.
+ redis_port (Optional[int]): Port of the Redis server. Defaults to None.
+ redis_password (Optional[str]): Password of the Redis server. Defaults to None.
+ cache_responses (Optional[bool]): Flag to enable caching of responses. Defaults to False.
+ cache_kwargs (dict): Additional kwargs to pass to RedisCache. Defaults to {}.
+ caching_groups (Optional[List[tuple]]): List of model groups for caching across model groups. Defaults to None.
+ client_ttl (int): Time-to-live for cached clients in seconds. Defaults to 3600.
+ polling_interval: (Optional[float]): frequency of polling queue. Only for '.scheduler_acompletion()'. Default is 3ms.
+ default_priority: (Optional[int]): the priority given to every chat or text completion request that sets no 'priority' of its own; '.acompletion()' and '.atext_completion()' queue it through the scheduler. Default is None.
+ num_retries (Optional[int]): Number of retries for failed requests. Defaults to 2.
+ timeout (Optional[float]): Timeout for requests. Defaults to None.
+ default_litellm_params (dict): Default parameters for Router.chat.completion.create. Defaults to {}.
+ set_verbose (bool): Flag to set verbose mode. Defaults to False.
+ debug_level (Literal["DEBUG", "INFO"]): Debug level for logging. Defaults to "INFO".
+ fallbacks (List): List of fallback options. Defaults to [].
+ context_window_fallbacks (List): List of context window fallback options. Defaults to [].
+ enable_pre_call_checks (boolean): Filter out deployments which are outside context window limits for a given prompt
+ model_group_alias (Optional[dict]): Alias for model groups. Defaults to {}.
+ retry_after (int): Minimum time to wait before retrying a failed request. Defaults to 0.
+ allowed_fails (Optional[int]): Number of allowed fails before adding to cooldown. Defaults to None.
+ cooldown_time (float): Time to cooldown a deployment after failure in seconds. Defaults to 1.
+ routing_strategy (Literal["simple-shuffle", "least-busy", "usage-based-routing", "latency-based-routing", "cost-based-routing"]): Routing strategy used for the implicit "default" group (any model not claimed by an entry in `routing_groups`). Defaults to "simple-shuffle".
+ routing_strategy_args (dict): Additional args for the default group's routing strategy (e.g. latency window). Defaults to {}.
+ routing_groups (Optional[List[RoutingGroup]]): Named subsets of `model_name`s with a group routing strategy.
+ Priority groups apply only to group calls and may overlap. Other groups supply their members' default
+ strategy, with at most one such group per model. Unclaimed models use the top-level strategy.
+ alerting_config (AlertingConfig): Slack alerting configuration. Defaults to None.
+ provider_budget_config (ProviderBudgetConfig): Provider budget configuration. Use this to set llm_provider budget limits. example $100/day to OpenAI, $100/day to Azure, etc. Defaults to None.
+ deployment_affinity_ttl_seconds (int): TTL for user-key -> deployment affinity mapping. Defaults to 3600.
+ session_affinity_repin_on_failover (bool): After a successful replacement response, move a session pin
+ whose deployment is in cooldown or removed. Async routes only. Defaults to False. Applied at startup.
+ ignore_invalid_deployments (bool): Ignores invalid deployments, and continues with other deployments. Default is to raise an error.
+ enable_weighted_failover (bool): When True and the routing strategy is "simple-shuffle", a retryable failure on one deployment causes the request to re-pick (weighted) across the other deployments in the same model group before any cross-group fallback runs. Bounded by `max_fallbacks`. Async-only: currently honored by `router.acompletion()` and other async entrypoints. The sync `router.completion()` path falls back to the regular fallback flow. Defaults to False.
+ enable_mid_stream_fallback_continuation (bool): When True, a chat-completions stream that breaks after plain assistant text has been delivered continues on a fallback deployment via assistant prefill instead of surfacing the error. Only deployments whose model supports assistant prefill are eligible, so the partial text is continued, not regenerated; if none is, the original error is surfaced. Streams that emitted tool calls, thinking blocks, audio/images, or constrained (JSON / forced tool_choice) output are never continued. Async-only. Defaults to False.
+ fallback_access_check (Optional[FallbackAccessCheck]): Awaited before each cross-model-group fallback attempt on the async path; a fallback target it rejects is skipped. Defaults to None (every configured fallback is attempted).
+ fallback_budget_check (Optional[FallbackBudgetCheck]): Awaited before each cross-model-group fallback attempt on the async path; a fallback target it rejects as over budget is skipped. Defaults to None (budget is not re-checked on fallback).
+ Returns:
+ Router: An instance of the litellm.Router class.
+
+ Example Usage:
+ ```python
+ from litellm import Router
+ model_list = [
+ {
+ "model_name": "azure-gpt-3.5-turbo", # model alias
+ "litellm_params": { # params for litellm completion/embedding call
+ "model": "azure/",
+ "api_key": ,
+ "api_version": ,
+ "api_base":
+ },
+ },
+ {
+ "model_name": "azure-gpt-3.5-turbo", # model alias
+ "litellm_params": { # params for litellm completion/embedding call
+ "model": "azure/",
+ "api_key": ,
+ "api_version": ,
+ "api_base":
+ },
+ },
+ {
+ "model_name": "openai-gpt-3.5-turbo", # model alias
+ "litellm_params": { # params for litellm completion/embedding call
+ "model": "gpt-3.5-turbo",
+ "api_key": ,
+ },
+ ]
+
+ router = Router(model_list=model_list, fallbacks=[{"azure-gpt-3.5-turbo": ["openai-gpt-3.5-turbo"]}])
+ ```
+ """
+ self._host: Final = _host
+ self._core_ctx: Final = RouterContext(self)
+
+ self.set_verbose = set_verbose
+ self.ignore_invalid_deployments = ignore_invalid_deployments
+ self.auto_router_capability_limit = auto_router_capability_limit
+ self._core_fallback_access_check: Final = fallback_access_check
+ self._core_fallback_budget_check: Final = fallback_budget_check
+ self.debug_level = debug_level
+ self._core_enable_pre_call_checks = enable_pre_call_checks
+ self._core_enable_tag_filtering = enable_tag_filtering
+ self._core_tag_filtering_match_any = tag_filtering_match_any
+ self._core_tag_routing_prefix = tag_routing_prefix
+ from litellm._service_logger import ServiceLogging
+
+ self.service_logger_obj: ServiceLogging = ServiceLogging()
+ self._host.suppress_debug_info() # prevents 'Give Feedback/Get help' message from being emitted on Router - Relevant Issue: https://github.com/BerriAI/litellm/issues/5942
+ if self.set_verbose is True:
+ if debug_level == "INFO":
+ verbose_router_logger.setLevel(logging.INFO)
+ elif debug_level == "DEBUG":
+ verbose_router_logger.setLevel(logging.DEBUG)
+ self._core_router_general_settings: RouterGeneralSettings = router_general_settings or RouterGeneralSettings()
+
+ self.assistants_config = assistants_config
+ self._core_search_tools = search_tools or []
+ self._core_guardrail_list = guardrail_list or []
+ self._core_deployment_names: list[str] = [] # names of models under litellm_params. ex. azure/chatgpt-v-2
+ self._core_deployment_latency_map = {}
+ ### CACHING ###
+ cache_type: Literal["local", "redis", "redis-semantic", "s3"] = "local" # default to an in-memory cache
+ redis_cache = None
+ cache_config: Final[dict[str, Any]] = {}
+
+ self.client_ttl = client_ttl
+ self._core_kubernetes_pod_discovery: Final = KubernetesPodDiscovery()
+ if redis_url is not None or (redis_host is not None and redis_port is not None):
+ cache_type = "redis"
+
+ if redis_url is not None:
+ cache_config["url"] = redis_url
+
+ if redis_host is not None:
+ cache_config["host"] = redis_host
+
+ if redis_port is not None:
+ cache_config["port"] = str(redis_port)
+
+ if redis_password is not None:
+ cache_config["password"] = redis_password
+
+ if redis_db is not None:
+ verbose_router_logger.warning(
+ "Deprecated 'redis_db' argument used. Please remove 'redis_db' from your config/database and use 'cache_kwargs' instead."
+ )
+ cache_config["db"] = str(redis_db)
+
+ # Add additional key-value pairs from cache_kwargs
+ cache_config.update(cache_kwargs)
+ redis_cache = self._create_redis_cache(cache_config)
+
+ if cache_responses:
+ if self._host.global_cache() is None:
+ # the cache can be initialized on the proxy server. We should not overwrite it
+ self._host.set_global_cache(LitellmCache(type=cache_type, **cache_config))
+ self._core_cache_responses = cache_responses
+ self._core_cache = DualCache(
+ redis_cache=redis_cache, in_memory_cache=InMemoryCache()
+ ) # use a dual cache (Redis+In-Memory) for tracking cooldowns, usage, etc.
+ self._claude_code_session_router_cache: DualCache = DualCache(
+ redis_cache=redis_cache,
+ in_memory_cache=InMemoryCache(),
+ )
+ self._core_prompt_caching_pin_cache: DualCache = DualCache(
+ redis_cache=redis_cache,
+ in_memory_cache=InMemoryCache(max_size_in_memory=PROMPT_CACHE_PIN_CACHE_MAX_SIZE),
+ )
+
+ ### SCHEDULER ###
+ self.scheduler = Scheduler(polling_interval=polling_interval, redis_cache=redis_cache)
+ parsed_default_priority: Final = parse_default_priority(default_priority)
+ if isinstance(parsed_default_priority, InvalidPriority):
+ verbose_router_logger.warning(
+ "router_settings.default_priority is ignored, no request is queued by default: %s",
+ parsed_default_priority.message,
+ )
+ self.default_priority = (
+ None if isinstance(parsed_default_priority, InvalidPriority) else parsed_default_priority
+ )
+ self._core_default_deployment = (
+ None # use this to track the users default deployment, when they want to use model = *
+ )
+ self._core_default_max_parallel_requests = default_max_parallel_requests
+ self._core_provider_default_deployment_ids: list[str] = []
+ self._core_pattern_router = PatternMatchRouter()
+ self._core_team_pattern_routers: dict[str, PatternMatchRouter] = {} # {"TEAM_ID": PatternMatchRouter}
+ self._core_auto_routers: dict[str, list[TaggedPreRoutingStrategy[AutoRouter]]] = {}
+ self._core_complexity_routers: dict[str, list[TaggedPreRoutingStrategy[ComplexityRouter]]] = {}
+ self._core_adaptive_routers: dict[str, list[TaggedPreRoutingStrategy[AdaptiveRouter]]] = {}
+ self._core_quality_routers: dict[str, list[TaggedPreRoutingStrategy[QualityRouter]]] = {}
+ self._core_routing_plugins: list[RoutingPlugin] = list(plugins) if plugins else []
+
+ # Initialize model_group_alias early since it's used in set_model_list
+ self._core_model_group_alias: dict[str, str | RouterModelGroupAliasItem] = (
+ model_group_alias or {}
+ ) # dict to store aliases for router, ex. {"gpt-4": "gpt-3.5-turbo"}, all requests with gpt-4 -> get routed to gpt-3.5-turbo group
+
+ # Initialize model ID to deployment index mapping for O(1) lookups
+ self.model_id_to_deployment_index_map: dict[str, int] = {}
+ # Initialize model name to deployment indices mapping for O(1) lookups
+ # Maps model_name -> list of indices in model_list
+ self._core_model_name_to_deployment_indices: dict[str, list[int]] = {}
+ # Maps (team_id, team_public_model_name) -> list of indices in model_list
+ self._core_team_model_to_deployment_indices: dict[tuple[str, str], list[int]] = {}
+ self._core_team_public_model_names: frozenset[str] = frozenset()
+ # Guards model_list and the three index maps above: writers change them, and readers turn an
+ # index into a deployment, under it, so a lookup never lands between a removal and its re-index
+ self._deployment_index_lock = threading.RLock()
+
+ # Initialize cache attributes that ``_invalidate_model_group_info_cache``
+ # and ``_invalidate_access_groups_cache`` touch *before* the first
+ # ``set_model_list`` below (which calls those invalidations as part of
+ # building the model index) and before ``_init_routing_groups(None)``
+ # (which calls them on every group rebuild).
+ self._access_groups_cache: dict[str, list[str]] | None = None
+ # Per-router cache for the proxy auth-layer "is this model explicitly
+ # zero-cost?" check. Lives on the router so it is invalidated alongside
+ # ``_cached_get_model_group_info`` and dies with the router (no
+ # ``id()``-reuse risk after GC). See
+ # ``litellm.proxy.auth.auth_checks._is_model_cost_zero``.
+ self._zero_cost_cache: dict[str, bool] = {}
+ self._core_cached_deployment_model_info = lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)(
+ self._core_get_deployment_model_info
+ )
+ self._ptu_share_teams = lru_cache(maxsize=1)(self._collect_ptu_share_teams)
+ self._cached_team_ptu_ceiling = lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)(self._resolve_team_ptu_ceiling)
+ self._cached_deployment_tracks_usage = lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)(
+ self._deployment_tracks_usage
+ )
+ self.served_openai_models = lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)(self._served_openai_models)
+ self._discovered_model_info_cache: InMemoryCache = InMemoryCache(
+ max_size_in_memory=max(len(model_list or ()), 1),
+ default_ttl=2 * MODEL_INFO_REFRESH_SECONDS,
+ )
+ self._routing_group_rows: tuple[DeploymentTypedDict, ...] | None = None
+ self._init_routing_groups(None)
+ self._provider_unresolved_deployments: tuple[Callable[[], Deployment | None], ...] = ()
+
+ self.deployment_affinity_ttl_seconds = deployment_affinity_ttl_seconds
+ self.session_affinity_repin_on_failover = session_affinity_repin_on_failover
+ self.model_group_affinity_config = model_group_affinity_config
+ warn_on_unknown_model_group_affinity_flags(model_group_affinity_config)
+
+ if model_list is not None:
+ # set_model_list will build indices automatically
+ self.set_model_list(model_list)
+ self.healthy_deployments: list = self._core_model_list
+ for m in model_list:
+ if "model" in m["litellm_params"]:
+ self._core_deployment_latency_map[m["litellm_params"]["model"]] = 0
+ else:
+ self._core_model_list: list = [] # initialize an empty list - to allow _add_deployment and delete_deployment to work
+ # Track this router so a price data reload can rebuild its deployments'
+ # cost-map entries from the list it is serving at that moment, whether
+ # those deployments came from model_list or were added later from the DB.
+ _live_routers.add(self)
+
+ if allowed_fails is not None:
+ self._core_allowed_fails = allowed_fails
+ else:
+ self._core_allowed_fails = self._host.dynamic().allowed_fails
+ self.explicit_allowed_fails = allowed_fails
+ self._core_cooldown_time = cooldown_time or DEFAULT_COOLDOWN_TIME_SECONDS
+ self._core_cooldown_cache = CooldownCache(
+ cache=self._core_cache, default_cooldown_time=self._core_cooldown_time
+ )
+ self._core_disable_cooldowns = disable_cooldowns
+ self.fallback_on_unsupported_params = fallback_on_unsupported_params
+ self._core_enable_health_check_routing = enable_health_check_routing
+ self.enable_weighted_failover = enable_weighted_failover
+ self.enable_mid_stream_fallback_continuation = self._is_flag_enabled(enable_mid_stream_fallback_continuation)
+ self._core_health_check_ignore_transient_errors = health_check_ignore_transient_errors
+ self._core_background_health_check_model_groups: frozenset[str] | None = (
+ frozenset(background_health_check_model_groups)
+ if background_health_check_model_groups is not None
+ else None
+ )
+ _staleness: Final = health_check_staleness_threshold or (
+ DEFAULT_HEALTH_CHECK_INTERVAL * DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER
+ )
+ self._core_health_state_cache = DeploymentHealthCache(
+ cache=self._core_cache, staleness_threshold=float(_staleness)
+ )
+
+ if num_retries is not None:
+ self.num_retries = num_retries
+ elif self._host.dynamic().num_retries is not None:
+ self.num_retries = self._host.dynamic().num_retries
+ else:
+ self.num_retries = openai.DEFAULT_MAX_RETRIES
+
+ if max_fallbacks is not None:
+ self.max_fallbacks = max_fallbacks
+ elif self._host.dynamic().max_fallbacks is not None:
+ self.max_fallbacks = self._host.dynamic().max_fallbacks
+ else:
+ self.max_fallbacks = self._host.dynamic().router_max_fallbacks
+
+ self._explicit_timeout = timeout # None when user did not pass timeout
+ self.timeout = timeout or self._host.dynamic().request_timeout
+ # Per-attempt request_timeout, independent of router_settings.timeout.
+ # Only stored when a router timeout is also set, since otherwise
+ # request_timeout already flows through self.timeout above.
+ self.request_timeout = get_configured_request_timeout() if timeout is not None else None
+ self.stream_timeout = stream_timeout
+
+ self.retry_after = retry_after
+ self._core_routing_strategy = self._normalize_strategy(routing_strategy)
+ self._routing_groups_input: list[RoutingGroup | dict] | None = routing_groups
+
+ ## SETTING FALLBACKS ##
+ ### validate if it's set + in correct format
+ _fallbacks = fallbacks or self._host.dynamic().fallbacks
+
+ self.validate_fallbacks(fallback_param=_fallbacks)
+ ### set fallbacks
+ self._core_fallbacks = _fallbacks
+
+ if default_fallbacks is not None or self._host.dynamic().default_fallbacks is not None:
+ _fallbacks = default_fallbacks or self._host.dynamic().default_fallbacks
+ if isinstance(_fallbacks, str):
+ raise TypeError(
+ f"default_fallbacks must be a list of model names, got '{_fallbacks}'. Use ['{_fallbacks}']"
+ )
+ if self._core_fallbacks is not None:
+ self._core_fallbacks.append({"*": _fallbacks})
+ else:
+ self._core_fallbacks = [{"*": _fallbacks}]
+
+ self._core_context_window_fallbacks = context_window_fallbacks or self._host.dynamic().context_window_fallbacks
+
+ _content_policy_fallbacks: Final = content_policy_fallbacks or self._host.dynamic().content_policy_fallbacks
+ self.validate_fallbacks(fallback_param=_content_policy_fallbacks)
+ self._core_content_policy_fallbacks = _content_policy_fallbacks
+ self.total_calls: defaultdict = defaultdict(int) # dict to store total calls made to each model
+ self.fail_calls: defaultdict = defaultdict(int) # dict to store fail_calls made to each model
+ self.success_calls: defaultdict = defaultdict(int) # dict to store success_calls made to each model
+
+ # make Router.chat.completions.create compatible for openai.chat.completions.create
+ default_litellm_params = default_litellm_params or {}
+ self.chat = LitellmChat(params=default_litellm_params, router_obj=self._core_public())
+
+ # default litellm args
+ self._core_default_litellm_params = default_litellm_params
+ self._core_default_litellm_params.setdefault("timeout", timeout)
+ self._core_default_litellm_params.setdefault("max_retries", 0)
+ self._core_default_litellm_params.setdefault("metadata", {}).update({"caching_groups": caching_groups})
+
+ self.deployment_stats: dict = {} # used for debugging load balancing
+ """
+ deployment_stats = {
+ "122999-2828282-277:
+ {
+ "model": "gpt-3",
+ "api_base": "http://localhost:4000",
+ "num_requests": 20,
+ "avg_latency": 0.001,
+ "num_failures": 0,
+ "num_successes": 20
+ }
+ }
+ """
+
+ ### ROUTING SETUP ###
+ if self._normalize_strategy(routing_strategy) == "lar1":
+ from litellm.router_strategy.lar1_routing import apply_lar1_routing_strategy
+
+ apply_lar1_routing_strategy(self._core_ctx, routing_strategy_args)
+ else:
+ self.routing_strategy_init(
+ routing_strategy=routing_strategy,
+ routing_strategy_args=routing_strategy_args,
+ )
+ self._init_routing_groups(self._routing_groups_input)
+ self._override_selectors: dict[str, RouterStrategySelector | None] = {}
+ self._override_selectors_lock = threading.Lock()
+ self._request_scoped_selectors: tuple[CustomLogger, ...] = ()
+ self._request_scoped_policies: tuple[CustomLogger, ...] = ()
+ self.access_groups = None
+ with _routers_holding_global_callbacks_lock:
+ _routers_holding_global_callbacks.add(self)
+ ## USAGE TRACKING ##
+ self._host.callbacks.add_litellm_async_success_callback(self.deployment_callback_on_success)
+ if isinstance(self._host.callback_list("success_callback"), list):
+ self._host.callbacks.add_litellm_success_callback(self.sync_deployment_callback_on_success)
+ else:
+ self._host.set_callback_list("success_callback", [self.sync_deployment_callback_on_success])
+ self._host.callbacks.add_litellm_async_failure_callback(self.async_deployment_callback_on_failure)
+ ## COOLDOWNS ##
+ if isinstance(self._host.callback_list("failure_callback"), list):
+ self._host.callbacks.add_litellm_failure_callback(self.deployment_callback_on_failure)
+ else:
+ self._host.set_callback_list("failure_callback", [self.deployment_callback_on_failure])
+ self.routing_strategy_args = routing_strategy_args
+ self._core_provider_budget_config = provider_budget_config
+ self.router_budget_logger: RouterBudgetLimiting | None = None
+ if RouterBudgetLimiting.should_init_router_budget_limiter(
+ model_list=model_list, provider_budget_config=self._core_provider_budget_config
+ ):
+ if optional_pre_call_checks is not None:
+ optional_pre_call_checks.append("router_budget_limiting")
+ else:
+ optional_pre_call_checks = ["router_budget_limiting"]
+ self.retry_policy: RetryPolicy | None = None
+ if retry_policy is not None:
+ if isinstance(retry_policy, dict):
+ self.retry_policy = RetryPolicy(**retry_policy)
+ elif isinstance(retry_policy, RetryPolicy):
+ self.retry_policy = retry_policy
+ if self.retry_policy is not None:
+ verbose_router_logger.info(
+ "\x1b[32mRouter Custom Retry Policy Set:\n%s\x1b[0m",
+ self.retry_policy.model_dump(exclude_none=True),
+ )
+
+ self.model_group_retry_policy: dict[str, RetryPolicy] | None = model_group_retry_policy
+
+ self._core_allowed_fails_policy: AllowedFailsPolicy | None = None
+ if allowed_fails_policy is not None:
+ if isinstance(allowed_fails_policy, dict):
+ self._core_allowed_fails_policy = AllowedFailsPolicy(**allowed_fails_policy)
+ elif isinstance(allowed_fails_policy, AllowedFailsPolicy):
+ self._core_allowed_fails_policy = allowed_fails_policy
+
+ if self._core_allowed_fails_policy is not None:
+ verbose_router_logger.info(
+ "\x1b[32mRouter Custom Allowed Fails Policy Set:\n%s\x1b[0m",
+ self._core_allowed_fails_policy.model_dump(exclude_none=True),
+ )
+
+ self.alerting_config: AlertingConfig | None = alerting_config
+
+ if optional_pre_call_checks is not None:
+ self.add_optional_pre_call_checks(optional_pre_call_checks)
+
+ # If model_group_affinity_config is set but no global affinity checks were
+ # enabled, we still need the DeploymentAffinityCheck callback (with global
+ # flags all False) so per-group config can activate affinity per model group.
+ if self.model_group_affinity_config:
+ self._ensure_deployment_affinity_callback()
+
+ if self.alerting_config is not None:
+ self._initialize_alerting()
+
+ self.initialize_assistants_endpoint()
+ self.initialize_router_endpoints()
+ self.apply_default_settings()
+ # From here on a settings write is a changeset (``__setattr__``).
+ self.__dict__["_core_ready"] = True
+
+ @staticmethod
+ def get_valid_args() -> list[str]:
+ """
+ Returns a list of valid arguments for the Router.__init__ method.
+ """
+ arg_spec: Final = inspect.getfullargspec(PythonCore.__init__)
+ return [arg for arg in (*arg_spec.args, *arg_spec.kwonlyargs) if arg not in ("self", "_host")]
+
+ def apply_default_settings(self):
+ """
+ Apply the default settings to the router.
+ """
+
+ default_pre_call_checks: Final[OptionalPreCallChecks] = []
+ self.add_optional_pre_call_checks(default_pre_call_checks)
+
+ if self.enable_mid_stream_fallback_continuation:
+ self._register_continuation_prefill_check()
+
+ def _register_continuation_prefill_check(self) -> None:
+ # Process-global on purpose: Router._core_discard() must not drop a filter another router still needs.
+ self._host.callbacks.add_litellm_callback(ContinuationPrefillDeploymentCheck())
+
+ def _core_discard(self) -> None:
+ """
+ Pseudo-destructor to be invoked to clean up global data structures when router is no longer used.
+ For now, unhook router's callbacks from all lists
+ """
+ # Stop contributing to cost-map rebuilds straight away rather than waiting
+ # for this router to be collected.
+ _live_routers.discard(self)
+ self.retire_routing_state()
+ self._release_global_callbacks()
+
+ def _release_global_callbacks(self) -> None:
+ with _routers_holding_global_callbacks_lock:
+ _routers_holding_global_callbacks.discard(self)
+ slack_alerting_loggers: Final = self._slack_alerting_loggers()
+ adaptive_router_hooks: Final = self._adaptive_router_hooks()
+ vacated_keys: Final = self._host.callbacks.drop_callbacks(
+ (
+ *self._callback_selectors(),
+ *self._override_selectors.values(),
+ *(self.optional_callbacks or ()),
+ *slack_alerting_loggers,
+ *adaptive_router_hooks,
+ ),
+ method_owners=(self, *slack_alerting_loggers),
+ )
+ with _routers_holding_global_callbacks_lock:
+ survivors: Final = tuple(_routers_holding_global_callbacks)
+ candidates: Final = chain.from_iterable(router._global_callback_candidates() for router in survivors)
+ for candidate in self._host.callbacks.with_custom_logger_keys(candidates, vacated_keys):
+ self._register_router_selector(candidate)
+ adaptive_keys: Final = frozenset(map(self._host.callbacks.get_custom_logger_key, adaptive_router_hooks))
+ adaptive_survivor: Final = next((router for router in survivors if router._core_adaptive_routers), None)
+ if adaptive_survivor is not None and vacated_keys & adaptive_keys:
+ adaptive_survivor._sync_adaptive_router_hooks()
+
+ def _core_serve_through_request_logging(self) -> None:
+ self._request_scoped_selectors = (*self._callback_selectors(), *self._request_adaptive_router_hooks())
+ self._request_scoped_policies = (*self._optional_loggers(), *self._slack_alerting_loggers())
+ self._release_global_callbacks()
+
+ def _core_bind_to_request_logging(self, logging_obj: LiteLLMLogging) -> None:
+ logging_obj.add_dynamic_function_callbacks(
+ async_success=self.deployment_callback_on_success,
+ failure=self.deployment_callback_on_failure,
+ async_failure=self.async_deployment_callback_on_failure,
+ )
+ for selector in self._request_scoped_selectors:
+ logging_obj.replace_global_callback(selector)
+ for policy in self._request_policies_not_set_globally():
+ logging_obj.add_dynamic_callback(policy)
+
+ def _request_policies_not_set_globally(self) -> tuple[CustomLogger, ...]:
+ global_keys: Final = frozenset(
+ map(
+ self._host.callbacks.get_custom_logger_key,
+ (callback for callback in self._host.callback_list("callbacks") if isinstance(callback, CustomLogger)),
+ )
+ )
+ return tuple(
+ policy
+ for policy in self._request_scoped_policies
+ if self._host.callbacks.get_custom_logger_key(policy) not in global_keys
+ )
+
+ def _request_adaptive_router_hooks(self) -> tuple[CustomLogger, ...]:
+ from litellm.router_strategy.adaptive_router.hooks import (
+ AdaptiveRouterPostCallHook,
+ )
+
+ return tuple(
+ AdaptiveRouterPostCallHook(adaptive_router=tagged.strategy)
+ for tagged in chain.from_iterable(self._core_adaptive_routers.values())
+ )
+
+ def _routing_callbacks(self) -> Iterable[object]:
+ if not (self._request_scoped_selectors or self._request_scoped_policies):
+ return self._host.callback_list("callbacks")
+ replaced_keys: Final = frozenset(
+ map(self._host.callbacks.get_custom_logger_key, self._request_scoped_selectors)
+ )
+ return chain(
+ self._host.callbacks.without_custom_logger_keys(self._host.callback_list("callbacks"), replaced_keys),
+ self._request_scoped_selectors,
+ self._request_policies_not_set_globally(),
+ )
+
+ def retire_routing_state(self) -> None:
+ for strategy in (*self._global_callback_candidates(), *self._override_selectors.values()):
+ if isinstance(strategy, BaseRoutingStrategy | RouterBudgetLimiting):
+ strategy.retire()
+
+ def _slack_alerting_loggers(self) -> tuple[CustomLogger, ...]:
+ slack_alerting_logger: Final = getattr(self, "_core_slack_alerting_logger", None)
+ return (slack_alerting_logger,) if isinstance(slack_alerting_logger, CustomLogger) else ()
+
+ def _adaptive_router_hooks(self) -> tuple[CustomLogger, ...]:
+ from litellm.router_strategy.adaptive_router.hooks import (
+ AdaptiveRouterPostCallHook,
+ )
+
+ own_strategy_ids: Final = frozenset(
+ id(tagged.strategy) for tagged in chain.from_iterable(self._core_adaptive_routers.values())
+ )
+ hooks: Final = self._host.callbacks.get_custom_loggers_for_type(AdaptiveRouterPostCallHook)
+ return tuple(
+ hook
+ for hook in hooks
+ if isinstance(hook, AdaptiveRouterPostCallHook) and id(hook.adaptive_router) in own_strategy_ids
+ )
+
+ def _optional_loggers(self) -> tuple[CustomLogger, ...]:
+ return tuple(callback for callback in self.optional_callbacks or () if isinstance(callback, CustomLogger))
+
+ def _global_callback_candidates(self) -> tuple[CustomLogger, ...]:
+ return (*self._callback_selectors(), *self._optional_loggers(), *self._slack_alerting_loggers())
+
+ @staticmethod
+ def _create_redis_cache(
+ cache_config: dict[str, Any],
+ ) -> RedisCache | RedisClusterCache:
+ """
+ Initializes either a RedisCache or RedisClusterCache based on the cache_config.
+ """
+ startup_nodes = cache_config.get("startup_nodes")
+ if not startup_nodes:
+ _env_cluster_nodes: Final = get_secret("REDIS_CLUSTER_NODES")
+ if _env_cluster_nodes is not None and isinstance(_env_cluster_nodes, str):
+ startup_nodes = _OPAQUE_VALUE_ADAPTER.validate_python(json.loads(_env_cluster_nodes))
+
+ if startup_nodes:
+ return RedisClusterCache(**{**cache_config, "startup_nodes": startup_nodes})
+ else:
+ return RedisCache(**cache_config)
+
+ def _core_update_redis_cache(self, cache: RedisCache) -> None:
+ """
+ Update the redis cache for the router, if none set.
+
+ Allows proxy user to just do
+ ```yaml
+ litellm_settings:
+ cache: true
+ ```
+ and caching to just work.
+ """
+ self._core_cache.attach_redis_cache(cache)
+ self._claude_code_session_router_cache.attach_redis_cache(cache)
+ self._core_prompt_caching_pin_cache.attach_redis_cache(cache)
+
+ # Maps a routing strategy string to the attribute on `self` that holds
+ # the default group's strategy selector for that strategy. (The selectors
+ # double as `CustomLogger` callbacks, hence the legacy `*_logger` attrs.)
+ _DEFAULT_SELECTOR_ATTR_BY_STRATEGY: dict[str, str] = {
+ "least-busy": "leastbusy_logger",
+ "usage-based-routing": "lowesttpm_logger",
+ "usage-based-routing-v2": "lowesttpm_logger_v2",
+ "latency-based-routing": "lowestlatency_logger",
+ "cost-based-routing": "lowestcost_logger",
+ }
+
+ @staticmethod
+ def _normalize_strategy(
+ strategy: RoutingStrategy | str | None,
+ ) -> str | None:
+ if strategy is None:
+ return None
+ if isinstance(strategy, RoutingStrategy):
+ return strategy.value
+ return strategy
+
+ @staticmethod
+ def _validate_routing_strategy(routing_strategy: RoutingStrategy | str | None) -> None:
+ validate_routing_strategy(routing_strategy)
+
+ def _build_strategy_selector(
+ self,
+ strategy: RoutingStrategy | str,
+ routing_strategy_args: dict,
+ register_callbacks: bool = True,
+ ) -> RouterStrategySelector | None:
+ """
+ Constructs a strategy selector for a given strategy.
+ Returns None for `simple-shuffle` (no selector needed) and unknown
+ strategies.
+ """
+ selector: RouterStrategySelector | None = None
+ match self._normalize_strategy(strategy):
+ case RoutingStrategy.LEAST_BUSY.value:
+ selector = LeastBusyLoggingHandler(router_cache=self._core_cache)
+ case RoutingStrategy.USAGE_BASED_ROUTING.value:
+ selector = LowestTPMLoggingHandler(
+ router_cache=self._core_cache,
+ routing_args=routing_strategy_args,
+ )
+ case RoutingStrategy.USAGE_BASED_ROUTING_V2.value:
+ selector = LowestTPMLoggingHandler_v2(
+ router_cache=self._core_cache,
+ routing_args=routing_strategy_args,
+ )
+ case RoutingStrategy.LATENCY_BASED.value:
+ selector = LowestLatencyLoggingHandler(
+ router_cache=self._core_cache,
+ routing_args=routing_strategy_args,
+ )
+ case RoutingStrategy.COST_BASED.value:
+ selector = LowestCostLoggingHandler(
+ router_cache=self._core_cache,
+ routing_args={},
+ )
+ case _:
+ pass
+
+ if selector is not None and register_callbacks:
+ self._register_router_selector(selector)
+
+ return selector
+
+ def _register_router_selector(self, selector: CustomLogger) -> None:
+ if isinstance(selector, LeastBusyLoggingHandler):
+ if isinstance(self._host.callback_list("input_callback"), list):
+ self._host.callbacks.add_litellm_input_callback(selector)
+ else:
+ self._host.set_callback_list("input_callback", [selector])
+ if isinstance(self._host.callback_list("callbacks"), list):
+ self._host.callbacks.add_litellm_callback(selector)
+
+ def _callback_selectors(self) -> tuple[RouterStrategySelector, ...]:
+ return tuple(
+ selector
+ for selector in (
+ *(getattr(self, attr, None) for attr in self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.values()),
+ *chain.from_iterable(selectors.values() for selectors in self._group_selectors.values()),
+ )
+ if selector is not None
+ )
+
+ def _unregister_router_selectors(self, selectors: Sequence[object]) -> None:
+ """
+ Drop router-owned strategy selectors from litellm's global callback
+ lists. Used before re-init (`routing_strategy_init` /
+ `_init_routing_groups`) and by `discard` so dead selectors stop
+ receiving callback events. `function_setup` copies `litellm.callbacks`
+ into the per-event lists on every request, so those are purged too.
+ """
+ for selector in selectors:
+ if isinstance(selector, BaseRoutingStrategy):
+ selector.retire()
+ self._host.callbacks.drop_callbacks(selectors)
+
+ def _apply_updated_routing_strategy_args(self) -> None:
+ """
+ Re-link the default group's selector to the current `routing_strategy_args`.
+
+ Selectors freeze their `RoutingArgs` at construction, so a runtime args
+ update would otherwise keep serving the boot-time values until restart.
+ Latency/usage state survives the rebuild: it lives in the shared router
+ cache, not on the selector.
+ """
+ strategy: Final = self._normalize_strategy(self._core_routing_strategy)
+ if strategy == "lar1":
+ from litellm.router_strategy.lar1_routing import apply_lar1_routing_strategy
+
+ apply_lar1_routing_strategy(self._core_ctx, self.routing_strategy_args)
+ return
+
+ attr: Final = self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.get(strategy or "")
+ current: Final = getattr(self, attr, None) if attr is not None else None
+ if attr is None or current is None:
+ return
+
+ try:
+ rebuilt: Final = self._build_strategy_selector(
+ strategy=strategy or "",
+ routing_strategy_args=self.routing_strategy_args,
+ )
+ except (TypeError, ValidationError):
+ verbose_router_logger.exception(
+ "Invalid routing_strategy_args %s for '%s'; keeping the previous ones",
+ self.routing_strategy_args,
+ strategy,
+ )
+ return
+
+ self._unregister_router_selectors((current,))
+ setattr(self, attr, rebuilt)
+
+ def routing_strategy_init(self, routing_strategy: RoutingStrategy | str, routing_strategy_args: dict):
+ verbose_router_logger.info("Routing strategy: %s", routing_strategy)
+ self._validate_routing_strategy(routing_strategy)
+ self._reset_custom_routing_strategy()
+
+ self._unregister_router_selectors(
+ [getattr(self, attr, None) for attr in self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.values()]
+ + list(getattr(self, "_override_selectors", {}).values())
+ )
+ self._override_selectors = {}
+
+ self.leastbusy_logger: LeastBusyLoggingHandler | None = None
+ self.lowesttpm_logger: LowestTPMLoggingHandler | None = None
+ self.lowesttpm_logger_v2: LowestTPMLoggingHandler_v2 | None = None
+ self.lowestlatency_logger: LowestLatencyLoggingHandler | None = None
+ self.lowestcost_logger: LowestCostLoggingHandler | None = None
+
+ selector: Final = self._build_strategy_selector(
+ strategy=routing_strategy,
+ routing_strategy_args=routing_strategy_args,
+ )
+ attr: Final = self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.get(self._normalize_strategy(routing_strategy) or "")
+ # TODO: legacy `self._logger` attributes are read directly by
+ # `get_settings()` and external callers. Fold the default group into
+ # `self._group_selectors["default"]` and drop these attribute writes —
+ # the dual storage is an antipattern preserved only for back-compat.
+ if attr is not None:
+ setattr(self, attr, selector)
+
+ def _init_routing_groups(
+ self,
+ groups_input: list[RoutingGroup | dict] | None,
+ ) -> None:
+ """
+ Validates and indexes `routing_groups`. Each `model_name` may belong to
+ at most one non-priority group. Constructs per-group strategy selectors so
+ groups with different `routing_strategy_args` track independent state.
+
+ Models not claimed by a non-priority group are served by the implicit
+ `"default"` group, whose selectors are the `self._logger`
+ attributes set up in `routing_strategy_init`.
+ """
+ if not groups_input:
+ self._replace_routing_groups(())
+ return
+
+ known_model_names: Final = frozenset(
+ m["model_name"] for m in (self._core_model_list or ()) if m.get("model_name")
+ )
+ groups: Final = parse_routing_groups(groups_input, known_model_names=known_model_names)
+
+ alias_names: Final = frozenset(self._core_model_group_alias or ())
+ for group in groups:
+ if group.group_name in known_model_names or group.group_name in alias_names:
+ if group.routing_strategy == "priority":
+ raise ValueError("Priority routing group names must not shadow a model or alias")
+ verbose_router_logger.warning(
+ "routing_groups: group_name '%s' is shadowed by an existing model_name or model_group_alias; "
+ "the group's strategy still applies to its members, but the name is not callable until renamed.",
+ group.group_name,
+ )
+
+ built: Final = tuple(
+ (
+ group,
+ self._build_strategy_selector(
+ strategy="simple-shuffle" if group.routing_strategy == "priority" else group.routing_strategy,
+ routing_strategy_args=group.routing_strategy_args or {},
+ register_callbacks=False,
+ ),
+ )
+ for group in groups
+ )
+ self._replace_routing_groups(built)
+
+ def _replace_routing_groups(
+ self,
+ built: tuple[tuple[RoutingGroup, RouterStrategySelector | None], ...],
+ ) -> None:
+ previous_selectors: Final[Mapping[str, Mapping[str, RouterStrategySelector]]] = getattr(
+ self, "_group_selectors", {}
+ )
+ self._unregister_router_selectors(
+ tuple(sel for selectors in previous_selectors.values() for sel in selectors.values())
+ )
+ for _, selector in built:
+ if selector is not None:
+ self._register_router_selector(selector)
+
+ self._core_routing_groups: dict[str, RoutingGroup] = {group.group_name: group for group, _ in built}
+ self._model_to_group: dict[str, str] = {
+ model_name: group.group_name
+ for group, _ in built
+ if group.routing_strategy != "priority"
+ for model_name in group.models
+ }
+ self._group_selectors: dict[str, dict[str, RouterStrategySelector]] = {
+ group.group_name: (
+ {} if selector is None else {self._normalize_strategy(group.routing_strategy) or "": selector}
+ )
+ for group, selector in built
+ }
+ self._invalidate_model_group_info_cache()
+ self._invalidate_access_groups_cache()
+
+ def get_routing_group(self, model_name: str) -> RoutingGroup | None:
+ """
+ The routing group callable as `model_name`, or None. A real deployment
+ `model_name` added after init shadows a same-named group (mirroring
+ `_try_early_resolve_deployments_for_model_not_in_names`, where concrete
+ models win over indirection); config-time collisions are rejected by
+ `_init_routing_groups`.
+ """
+ if not self._core_routing_groups:
+ return None
+ group: Final = self._core_routing_groups.get(model_name)
+ if (
+ group is None
+ or model_name in self._core_model_name_to_deployment_indices
+ or model_name in (self._core_model_group_alias or {})
+ ):
+ return None
+ return group
+
+ def _get_routing_group_deployments(
+ self, model: str, team_id: str | None = None
+ ) -> list[DeploymentTypedDict] | None: # mutable-ok: list matches _get_all_deployments' contract for callers
+ """
+ The union of member deployments for a routing group called as `model`,
+ or None when `model` is not a callable group. The requested name stays
+ the group name so strategy selectors key their state by it.
+
+ `_common_checks_available_deployment` consults this BEFORE its
+ early-resolve step so a wildcard `default_deployment` or pattern route
+ cannot hijack a group call. Overall resolution precedence there:
+ specific deployment > model id > model_group_alias > routing group >
+ model_name > team/pattern/default fallbacks.
+ """
+ if not self._core_routing_groups:
+ return None
+ routing_group: Final = self.get_routing_group(model)
+ if routing_group is None:
+ return None
+ return [
+ apply_routing_group_priority(routing_group, member, deployment)
+ for member in routing_group.models
+ for deployment in self._core_get_all_deployments(model_name=member, team_id=team_id)
+ ]
+
+ def _is_priority_routing_group(self, model: str) -> bool:
+ resolved: Final = self._core_get_model_from_alias(model=model) or model
+ group: Final = self.get_routing_group(resolved)
+ return group is not None and group.routing_strategy == "priority"
+
+ def _core_is_recognized_model(self, model: str) -> bool:
+ """
+ Whether `model` names something this router serves directly: a
+ deployment model_name, a deployment id, a `model_group_alias`, or a
+ callable routing group. Proxy request gates share this predicate so a
+ new virtual-model kind cannot be forgotten at one of them; wildcard,
+ default-deployment, and deployment-name fallbacks stay caller policy.
+ """
+ return (
+ model in self._core_model_names
+ or self._core_has_model_id(model)
+ or (self._core_model_group_alias is not None and model in self._core_model_group_alias)
+ or self.get_routing_group(model) is not None
+ )
+
+ def _core_routing_group_has_alternatives(self, model_group: str | None) -> bool:
+ """
+ True when `model_group` names a callable routing group whose member
+ union spans more than one deployment. Cooldown handling passes the
+ FAILING REQUEST's model group here: a 429 on a group call cools the
+ member down so selection moves to the group's alternatives, while a
+ direct call to a single-deployment member keeps the
+ single-deployment-model-group cooldown exemption.
+ """
+ if model_group is None:
+ return False
+ resolved: Final = self._core_get_model_from_alias(model=model_group) or model_group
+ group: Final = self.get_routing_group(resolved)
+ if group is None:
+ return False
+ return sum(len(self._core_model_name_to_deployment_indices.get(member) or ()) for member in group.models) > 1
+
+ def _core_team_model_has_alternatives(self, deployment_id: str) -> bool:
+ deployment: Final = self._core_get_deployment(model_id=deployment_id)
+ if deployment is None:
+ return False
+ team_id: Final = deployment.model_info.team_id
+ public_model_name: Final = deployment.model_info.team_public_model_name
+ if team_id is None or public_model_name is None:
+ return False
+ siblings: Final = self._team_deployments_named(team_id, public_model_name) or ()
+ routable_siblings: Final = self._filter_blocked_deployments([dict(sibling) for sibling in siblings])
+ return len(routable_siblings) > 1
+
+ _OVERRIDABLE_ROUTING_STRATEGIES: frozenset[str] = frozenset({"simple-shuffle", *_DEFAULT_SELECTOR_ATTR_BY_STRATEGY})
+
+ def _get_request_routing_strategy_override(self, request_kwargs: dict | None) -> str | None:
+ """
+ Reads a per-request `routing_strategy` override (forwarded by the proxy
+ from key/team `router_settings`) out of the request kwargs.
+
+ Only strategies with a per-request-capable selector are honored;
+ anything else (unknown strings, `lar1`, `provider-budget-routing`) is
+ ignored with a warning so a bad value stored on a key or team can
+ never take down that caller's traffic.
+ """
+ if not request_kwargs:
+ return None
+ raw_strategy: Final = request_kwargs.get("routing_strategy")
+ if raw_strategy is None:
+ return None
+ strategy = self._normalize_strategy(raw_strategy) if isinstance(raw_strategy, (str, RoutingStrategy)) else None
+ if not isinstance(strategy, str) or strategy not in self._OVERRIDABLE_ROUTING_STRATEGIES:
+ verbose_router_logger.warning(
+ "Ignoring per-request routing_strategy override '%s'; supported overrides: %s.",
+ raw_strategy,
+ sorted(self._OVERRIDABLE_ROUTING_STRATEGIES),
+ )
+ return None
+ return strategy
+
+ def _get_override_strategy_selector(self, strategy: str) -> RouterStrategySelector | None:
+ """
+ Returns the selector for a per-request strategy override.
+
+ Reuses the default group's selector when the override matches the
+ router's configured strategy (so shared state keeps accumulating in
+ one place); otherwise lazily builds one selector per strategy and
+ caches it for the router's lifetime so its usage/latency state
+ persists across requests.
+ """
+ if strategy == self._normalize_strategy(self._core_routing_strategy):
+ attr: Final = self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.get(strategy)
+ return getattr(self, attr, None) if attr is not None else None
+ with self._override_selectors_lock:
+ if strategy not in self._override_selectors:
+ self._override_selectors[strategy] = self._build_strategy_selector(
+ strategy=strategy,
+ routing_strategy_args={},
+ register_callbacks=False,
+ )
+ return self._override_selectors[strategy]
+
+ def _override_selector_pre_call_check(
+ self, strategy: str | None, selector: RouterStrategySelector | None, deployment: dict
+ ) -> None:
+ """
+ Override selectors are not in `litellm.callbacks`, so the pre-call check that
+ `routing_strategy_pre_call_checks` runs for the router's own selectors (rpm
+ accounting for `usage-based-routing-v2`) runs here, for the overriding request only.
+ """
+ if selector is None or strategy is None or selector is not self._override_selectors.get(strategy):
+ return
+ selector.pre_call_check(deployment)
+
+ async def _async_override_selector_pre_call_check(
+ self,
+ strategy: str | None,
+ selector: RouterStrategySelector | None,
+ deployment: dict,
+ parent_otel_span: Span | None,
+ ) -> None:
+ if selector is None or strategy is None or selector is not self._override_selectors.get(strategy):
+ return
+ await selector.async_pre_call_check(deployment, parent_otel_span)
+
+ def _bind_override_selector_to_request(
+ self, strategy: str, selector: RouterStrategySelector | None, request_kwargs: Mapping[str, object] | None
+ ) -> None:
+ if selector is None or request_kwargs is None or strategy in self._globally_registered_strategies():
+ return
+ logging_obj: Final = request_kwargs.get("litellm_logging_obj")
+ if isinstance(logging_obj, LiteLLMLogging):
+ logging_obj.add_dynamic_callback(selector)
+
+ def _globally_registered_strategies(self) -> frozenset[str]:
+ configured: Final = (
+ self._core_routing_strategy,
+ *(group.routing_strategy for group in self._core_routing_groups.values()),
+ )
+ return frozenset(
+ normalized for normalized in map(self._normalize_strategy, configured) if normalized is not None
+ )
+
+ def _core_arm_routing_read_prefetch(self, model: str, request_kwargs: dict[str, object] | None = None) -> None:
+ """Declare the cooldown read (and, for usage-based routing, the usage read) that
+ `async_get_available_deployment` will make for `model` on the request's Redis batch, so admission's
+ flush carries it. A miss (alias, no batch) costs nothing: routing then reads as it always has."""
+ try:
+ strategy, selector = self._get_routing_context(model, request_kwargs)
+ usage_selector: Final = (
+ selector
+ if strategy == "usage-based-routing-v2" and isinstance(selector, LowestTPMLoggingHandler_v2)
+ else None
+ )
+ deployments: Final = self._core_get_model_list(model_name=model)
+ if deployments:
+ RoutingPrefetch.arm(self._core_ctx, usage_selector, deployments)
+ except Exception as e: # noqa: BLE001 # a prefetch is an optimisation, never a reason to fail the request
+ verbose_router_logger.debug(
+ "routing read prefetch not armed for %s: %s", _without_line_breaks(model), _without_line_breaks(e)
+ )
+
+ def _get_routing_context(
+ self, model: str, request_kwargs: dict | None = None
+ ) -> tuple[str | None, RouterStrategySelector | None]:
+ """
+ Resolves the routing strategy and selector to use for the given model.
+
+ A per-request `routing_strategy` in `request_kwargs` (forwarded by the
+ proxy from key/team `router_settings`) takes precedence over both the
+ model's routing group and the router's top-level strategy, since it is
+ the most specific expression of caller intent.
+
+ Otherwise every model belongs to exactly one group: an explicit entry
+ from `routing_groups` (either because `model` IS a callable group name,
+ or because it is a member of one), or the implicit `"default"` group
+ driven by the router's top-level `routing_strategy` /
+ `routing_strategy_args`.
+
+ `self._core_routing_strategy` may be either a string or a `RoutingStrategy`
+ enum member (the constructor accepts both), so it is normalized to a
+ string here. Downstream call sites and `_select_deployment_*` arms
+ compare against string literals.
+ """
+ override: Final = self._get_request_routing_strategy_override(request_kwargs)
+ if override is not None:
+ verbose_router_logger.debug("routing_group=request-override model=%s strategy=%s", model, override)
+ override_selector: Final = self._get_override_strategy_selector(override)
+ self._bind_override_selector_to_request(override, override_selector, request_kwargs)
+ return override, override_selector
+
+ resolved_model: Final = self._core_get_model_from_alias(model=model) or model
+ group_name: Final = (
+ resolved_model
+ if self.get_routing_group(resolved_model) is not None
+ else self._model_to_group.get(resolved_model)
+ )
+ if group_name is None:
+ strategy = self._normalize_strategy(self._core_routing_strategy)
+ attr: Final = self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.get(strategy or "")
+ selector = getattr(self, attr, None) if attr is not None else None
+ verbose_router_logger.debug("routing_group=default model=%s strategy=%s", model, strategy)
+ return strategy, selector
+
+ group: Final = self._core_routing_groups[group_name]
+ if group.routing_strategy == "priority":
+ return "simple-shuffle", None
+ strategy = self._normalize_strategy(group.routing_strategy)
+ selector = self._group_selectors.get(group_name, {}).get(strategy or "")
+ verbose_router_logger.debug("routing_group=%s model=%s strategy=%s", group_name, model, strategy)
+ return strategy, selector
+
+ async def _select_deployment_async(
+ self,
+ *,
+ strategy: str | None,
+ selector: Any | None,
+ model: str,
+ healthy_deployments: list,
+ messages: list[dict[str, str]] | None,
+ input: str | list | None,
+ request_kwargs: dict | None,
+ ) -> Any | None:
+ """
+ Asks the strategy selector for a deployment. Caller handles
+ `simple-shuffle` separately (it does not flow through a selector).
+ Returns None for unknown strategies or when the selector is missing.
+ """
+ if selector is None:
+ return None
+ match strategy:
+ case "least-busy":
+ return await selector.async_get_available_deployments(
+ model_group=model,
+ healthy_deployments=healthy_deployments,
+ )
+ case "usage-based-routing":
+ # `LowestTPMLoggingHandler` (v1) only exposes the sync
+ # `get_available_deployments`. Mirror the pre-routing-groups
+ # top-level fallback by calling it inline so groups using v1
+ # still work from async callers.
+ return selector.get_available_deployments(
+ model_group=model,
+ healthy_deployments=healthy_deployments,
+ messages=messages,
+ input=input,
+ )
+ case "usage-based-routing-v2" | "cost-based-routing":
+ return await selector.async_get_available_deployments(
+ model_group=model,
+ healthy_deployments=healthy_deployments,
+ messages=messages,
+ input=input,
+ )
+ case "latency-based-routing":
+ return await selector.async_get_available_deployments(
+ model_group=model,
+ healthy_deployments=healthy_deployments,
+ messages=messages,
+ input=input,
+ request_kwargs=request_kwargs,
+ )
+ case _:
+ return None
+
+ def _select_deployment_sync(
+ self,
+ *,
+ strategy: str | None,
+ selector: Any | None,
+ model: str,
+ healthy_deployments: list,
+ messages: list[dict[str, str]] | None,
+ input: str | list | None,
+ request_kwargs: dict | None,
+ ) -> Any | None:
+ """
+ Sync sibling of `_select_deployment_async`. Caller handles
+ `simple-shuffle` separately.
+ """
+ if selector is None:
+ return None
+
+ # `cost-based-routing` is intentionally omitted —
+ # `LowestCostLoggingHandler` only implements
+ # `async_get_available_deployments`
+ match strategy:
+ case "least-busy":
+ return selector.get_available_deployments(
+ model_group=model,
+ healthy_deployments=healthy_deployments,
+ )
+ case "usage-based-routing" | "usage-based-routing-v2":
+ return selector.get_available_deployments(
+ model_group=model,
+ healthy_deployments=healthy_deployments,
+ messages=messages,
+ input=input,
+ )
+ case "latency-based-routing":
+ return selector.get_available_deployments(
+ model_group=model,
+ healthy_deployments=healthy_deployments,
+ messages=messages,
+ input=input,
+ request_kwargs=request_kwargs,
+ )
+ case _:
+ return None
+
+ def initialize_assistants_endpoint(self):
+ ## INITIALIZE PASS THROUGH ASSISTANTS ENDPOINT ##
+ self.acreate_assistants = self.factory_function(self._host.invoke("acreate_assistants"))
+ self.adelete_assistant = self.factory_function(self._host.invoke("adelete_assistant"))
+ self.aget_assistants = self.factory_function(self._host.invoke("aget_assistants"))
+ self.acreate_thread = self.factory_function(self._host.invoke("acreate_thread"))
+ self.aget_thread = self.factory_function(self._host.invoke("aget_thread"))
+ self.a_add_message = self.factory_function(self._host.invoke("a_add_message"))
+ self.aget_messages = self.factory_function(self._host.invoke("aget_messages"))
+ self.arun_thread = self.factory_function(self._host.invoke("arun_thread"))
+
+ def _initialize_core_endpoints(self):
+ """Helper to initialize core router endpoints."""
+ self.amoderation = self.factory_function(self._host.invoke("amoderation"), call_type="moderation")
+ self.aanthropic_messages = self.factory_function(
+ self._host.invoke("anthropic_messages"), call_type="anthropic_messages"
+ )
+ self.anthropic_messages = self.factory_function(
+ self._host.invoke("anthropic_messages"), call_type="anthropic_messages"
+ )
+ self.agenerate_content = self.factory_function(
+ self._host.invoke("agenerate_content"), call_type="agenerate_content"
+ )
+ self.aadapter_generate_content = self.factory_function(
+ self._host.invoke("aadapter_generate_content"), call_type="aadapter_generate_content"
+ )
+ self.aresponses = self.factory_function(self._host.invoke("aresponses"), call_type="aresponses")
+ self.afile_delete = self.factory_function(self._host.invoke("afile_delete"), call_type="afile_delete")
+ self.afile_content = self.factory_function(self._host.invoke("afile_content"), call_type="afile_content")
+ self.responses = self.factory_function(self._host.invoke("responses"), call_type="responses")
+ self.aget_responses = self.factory_function(self._host.invoke("aget_responses"), call_type="aget_responses")
+ self.acancel_responses = self.factory_function(
+ self._host.invoke("acancel_responses"), call_type="acancel_responses"
+ )
+ self.acompact_responses = self.factory_function(
+ self._host.invoke("acompact_responses"), call_type="acompact_responses"
+ )
+ self.adelete_responses = self.factory_function(
+ self._host.invoke("adelete_responses"), call_type="adelete_responses"
+ )
+ self.alist_input_items = self.factory_function(
+ self._host.invoke("alist_input_items"), call_type="alist_input_items"
+ )
+ self._arealtime = self.factory_function(self._host.invoke("_arealtime"), call_type="_arealtime")
+ self.acreate_realtime_client_secret = self.factory_function(
+ self._host.invoke("acreate_realtime_client_secret"), call_type="acreate_realtime_client_secret"
+ )
+ self.arealtime_calls = self.factory_function(self._host.invoke("arealtime_calls"), call_type="arealtime_calls")
+ self.acreate_realtime_transcription_session = self.factory_function(
+ self._host.invoke("acreate_realtime_transcription_session"),
+ call_type="acreate_realtime_transcription_session",
+ )
+ self._aresponses_websocket = self.factory_function(
+ self._host.invoke("_aresponses_websocket"), call_type="_aresponses_websocket"
+ )
+ self.acreate_fine_tuning_job = self.factory_function(
+ self._host.invoke("acreate_fine_tuning_job"), call_type="acreate_fine_tuning_job"
+ )
+ self.acancel_fine_tuning_job = self.factory_function(
+ self._host.invoke("acancel_fine_tuning_job"), call_type="acancel_fine_tuning_job"
+ )
+ self.alist_fine_tuning_jobs = self.factory_function(
+ self._host.invoke("alist_fine_tuning_jobs"), call_type="alist_fine_tuning_jobs"
+ )
+ self.aretrieve_fine_tuning_job = self.factory_function(
+ self._host.invoke("aretrieve_fine_tuning_job"), call_type="aretrieve_fine_tuning_job"
+ )
+ self.afile_list = self.factory_function(self._host.invoke("afile_list"), call_type="alist_files")
+ self.aimage_edit = self.factory_function(self._host.invoke("aimage_edit"), call_type="aimage_edit")
+ self.allm_passthrough_route = self.factory_function(
+ self._host.invoke("allm_passthrough_route"), call_type="allm_passthrough_route"
+ )
+ # Note: acancel_batch is defined as a method on the Router class (not using factory_function)
+ # to properly handle model-to-provider mapping like acreate_batch and aretrieve_batch
+
+ def _initialize_vector_store_endpoints(self):
+ """Initialize vector store endpoints."""
+ from litellm.vector_stores.main import (
+ adelete,
+ alist,
+ aretrieve,
+ asearch,
+ aupdate,
+ create,
+ delete,
+ list,
+ retrieve,
+ search,
+ update,
+ )
+
+ self.avector_store_search = self.factory_function(asearch, call_type="avector_store_search")
+ self.vector_store_search = self.factory_function(search, call_type="vector_store_search")
+ self.vector_store_create = self.factory_function(create, call_type="vector_store_create")
+ self.avector_store_retrieve = self.factory_function(aretrieve, call_type="avector_store_retrieve")
+ self.vector_store_retrieve = self.factory_function(retrieve, call_type="vector_store_retrieve")
+ self.avector_store_list = self.factory_function(alist, call_type="avector_store_list")
+ self.vector_store_list = self.factory_function(list, call_type="vector_store_list")
+ self.avector_store_update = self.factory_function(aupdate, call_type="avector_store_update")
+ self.vector_store_update = self.factory_function(update, call_type="vector_store_update")
+ self.avector_store_delete = self.factory_function(adelete, call_type="avector_store_delete")
+ self.vector_store_delete = self.factory_function(delete, call_type="vector_store_delete")
+
+ def _initialize_vector_store_file_endpoints(self):
+ """Initialize vector store file endpoints."""
+ from litellm.vector_store_files.main import (
+ acreate as avector_store_file_create_fn,
+ )
+ from litellm.vector_store_files.main import (
+ adelete as avector_store_file_delete_fn,
+ )
+ from litellm.vector_store_files.main import alist as avector_store_file_list_fn
+ from litellm.vector_store_files.main import (
+ aretrieve as avector_store_file_retrieve_fn,
+ )
+ from litellm.vector_store_files.main import (
+ aretrieve_content as avector_store_file_content_fn,
+ )
+ from litellm.vector_store_files.main import (
+ aupdate as avector_store_file_update_fn,
+ )
+ from litellm.vector_store_files.main import (
+ create as vector_store_file_create_fn,
+ )
+ from litellm.vector_store_files.main import (
+ delete as vector_store_file_delete_fn,
+ )
+ from litellm.vector_store_files.main import list as vector_store_file_list_fn
+ from litellm.vector_store_files.main import (
+ retrieve as vector_store_file_retrieve_fn,
+ )
+ from litellm.vector_store_files.main import (
+ retrieve_content as vector_store_file_content_fn,
+ )
+ from litellm.vector_store_files.main import (
+ update as vector_store_file_update_fn,
+ )
+
+ self.avector_store_file_create = self.factory_function(
+ avector_store_file_create_fn, call_type="avector_store_file_create"
+ )
+ self.vector_store_file_create = self.factory_function(
+ vector_store_file_create_fn, call_type="vector_store_file_create"
+ )
+ self.avector_store_file_list = self.factory_function(
+ avector_store_file_list_fn, call_type="avector_store_file_list"
+ )
+ self.vector_store_file_list = self.factory_function(
+ vector_store_file_list_fn, call_type="vector_store_file_list"
+ )
+ self.avector_store_file_retrieve = self.factory_function(
+ avector_store_file_retrieve_fn, call_type="avector_store_file_retrieve"
+ )
+ self.vector_store_file_retrieve = self.factory_function(
+ vector_store_file_retrieve_fn, call_type="vector_store_file_retrieve"
+ )
+ self.avector_store_file_content = self.factory_function(
+ avector_store_file_content_fn, call_type="avector_store_file_content"
+ )
+ self.vector_store_file_content = self.factory_function(
+ vector_store_file_content_fn, call_type="vector_store_file_content"
+ )
+ self.avector_store_file_update = self.factory_function(
+ avector_store_file_update_fn, call_type="avector_store_file_update"
+ )
+ self.vector_store_file_update = self.factory_function(
+ vector_store_file_update_fn, call_type="vector_store_file_update"
+ )
+ self.avector_store_file_delete = self.factory_function(
+ avector_store_file_delete_fn, call_type="avector_store_file_delete"
+ )
+ self.vector_store_file_delete = self.factory_function(
+ vector_store_file_delete_fn, call_type="vector_store_file_delete"
+ )
+
+ def _initialize_google_genai_endpoints(self):
+ """Initialize Google GenAI endpoints."""
+ from litellm.google_genai import (
+ agenerate_content,
+ agenerate_content_stream,
+ generate_content,
+ generate_content_stream,
+ )
+
+ self.agenerate_content = self.factory_function(agenerate_content, call_type="agenerate_content")
+ self.generate_content = self.factory_function(generate_content, call_type="generate_content")
+ self.agenerate_content_stream = self.factory_function(
+ agenerate_content_stream, call_type="agenerate_content_stream"
+ )
+ self.generate_content_stream = self.factory_function(
+ generate_content_stream, call_type="generate_content_stream"
+ )
+
+ def _initialize_ocr_search_endpoints(self):
+ """Initialize OCR and search endpoints."""
+ from litellm.ocr import aocr, ocr
+
+ self.aocr = self.factory_function(aocr, call_type="aocr")
+ self.ocr = self.factory_function(ocr, call_type="ocr")
+
+ from litellm.search import asearch, search
+
+ self.asearch = self.factory_function(asearch, call_type="asearch")
+ self.search = self.factory_function(search, call_type="search")
+
+ from litellm.decisions import adecisions, decisions
+
+ self.adecisions = self.factory_function(adecisions, call_type="adecisions")
+ self.decisions = self.factory_function(decisions, call_type="decisions")
+
+ from litellm.systemone import asystemone, systemone
+
+ self.asystemone = self.factory_function(asystemone, call_type="asystemone") # pyright: ignore[reportUnknownMemberType] # factory_function takes a bare Callable
+ self.systemone = self.factory_function(systemone, call_type="systemone") # pyright: ignore[reportUnknownMemberType] # factory_function takes a bare Callable
+
+ from litellm.extraction import aextraction, extraction
+
+ self.aextraction = self.factory_function(aextraction, call_type="aextraction") # pyright: ignore[reportUnknownMemberType] # factory_function takes a bare Callable
+ self.extraction = self.factory_function(extraction, call_type="extraction") # pyright: ignore[reportUnknownMemberType] # factory_function takes a bare Callable
+
+ def _initialize_video_endpoints(self):
+ """Initialize video endpoints."""
+ from litellm.videos import (
+ avideo_content,
+ avideo_create_character,
+ avideo_edit,
+ avideo_extension,
+ avideo_generation,
+ avideo_get_character,
+ avideo_list,
+ avideo_remix,
+ avideo_status,
+ video_content,
+ video_create_character,
+ video_edit,
+ video_extension,
+ video_generation,
+ video_get_character,
+ video_list,
+ video_remix,
+ video_status,
+ )
+
+ self.avideo_generation = self.factory_function(avideo_generation, call_type="avideo_generation")
+ self.video_generation = self.factory_function(video_generation, call_type="video_generation")
+ self.avideo_list = self.factory_function(avideo_list, call_type="avideo_list")
+ self.video_list = self.factory_function(video_list, call_type="video_list")
+ self.avideo_status = self.factory_function(avideo_status, call_type="avideo_status")
+ self.video_status = self.factory_function(video_status, call_type="video_status")
+ self.avideo_content = self.factory_function(avideo_content, call_type="avideo_content")
+ self.video_content = self.factory_function(video_content, call_type="video_content")
+ self.avideo_remix = self.factory_function(avideo_remix, call_type="avideo_remix")
+ self.video_remix = self.factory_function(video_remix, call_type="video_remix")
+ self.avideo_create_character = self.factory_function(
+ avideo_create_character, call_type="avideo_create_character"
+ )
+ self.video_create_character = self.factory_function(video_create_character, call_type="video_create_character")
+ self.avideo_get_character = self.factory_function(avideo_get_character, call_type="avideo_get_character")
+ self.video_get_character = self.factory_function(video_get_character, call_type="video_get_character")
+ self.avideo_edit = self.factory_function(avideo_edit, call_type="avideo_edit")
+ self.video_edit = self.factory_function(video_edit, call_type="video_edit")
+ self.avideo_extension = self.factory_function(avideo_extension, call_type="avideo_extension")
+ self.video_extension = self.factory_function(video_extension, call_type="video_extension")
+
+ def _initialize_container_endpoints(self):
+ """Initialize container endpoints."""
+ from litellm.containers import (
+ acreate_container,
+ adelete_container,
+ alist_containers,
+ aretrieve_container,
+ create_container,
+ delete_container,
+ list_containers,
+ retrieve_container,
+ )
+ from litellm.containers.endpoint_factory import (
+ _generated_endpoints as container_file_endpoints,
+ )
+
+ self.acreate_container = self.factory_function(acreate_container, call_type="acreate_container")
+ self.create_container = self.factory_function(create_container, call_type="create_container")
+ self.alist_containers = self.factory_function(alist_containers, call_type="alist_containers")
+ self.list_containers = self.factory_function(list_containers, call_type="list_containers")
+ self.aretrieve_container = self.factory_function(aretrieve_container, call_type="aretrieve_container")
+ self.retrieve_container = self.factory_function(retrieve_container, call_type="retrieve_container")
+ self.adelete_container = self.factory_function(adelete_container, call_type="adelete_container")
+ self.delete_container = self.factory_function(delete_container, call_type="delete_container")
+
+ # Auto-register JSON-generated container file endpoints
+ for name, func in container_file_endpoints.items():
+ setattr(self, name, self.factory_function(func, call_type=name))
+
+ def _initialize_skills_endpoints(self):
+ """Initialize Anthropic Skills API endpoints."""
+ self.acreate_skill = self.factory_function(self._host.invoke("acreate_skill"), call_type="acreate_skill")
+ self.alist_skills = self.factory_function(self._host.invoke("alist_skills"), call_type="alist_skills")
+ self.aget_skill = self.factory_function(self._host.invoke("aget_skill"), call_type="aget_skill")
+ self.adelete_skill = self.factory_function(self._host.invoke("adelete_skill"), call_type="adelete_skill")
+
+ def _initialize_interactions_endpoints(self):
+ """Initialize Google Interactions API endpoints."""
+ from litellm.interactions import acancel as acancel_interaction
+ from litellm.interactions import acreate as acreate_interaction
+ from litellm.interactions import adelete as adelete_interaction
+ from litellm.interactions import aget as aget_interaction
+ from litellm.interactions import cancel as cancel_interaction
+ from litellm.interactions import create as create_interaction
+ from litellm.interactions import delete as delete_interaction
+ from litellm.interactions import get as get_interaction
+
+ self.acreate_interaction = self.factory_function(acreate_interaction, call_type="acreate_interaction")
+ self.create_interaction = self.factory_function(create_interaction, call_type="create_interaction")
+ self.aget_interaction = self.factory_function(aget_interaction, call_type="aget_interaction")
+ self.get_interaction = self.factory_function(get_interaction, call_type="get_interaction")
+ self.adelete_interaction = self.factory_function(adelete_interaction, call_type="adelete_interaction")
+ self.delete_interaction = self.factory_function(delete_interaction, call_type="delete_interaction")
+ self.acancel_interaction = self.factory_function(acancel_interaction, call_type="acancel_interaction")
+ self.cancel_interaction = self.factory_function(cancel_interaction, call_type="cancel_interaction")
+
+ def _initialize_managed_agents_endpoints(self):
+ """Initialize Google Managed Agents API endpoints (v1beta/agents)."""
+ from litellm.interactions.agents import acreate as acreate_agent
+ from litellm.interactions.agents import adelete as adelete_agent
+ from litellm.interactions.agents import aget as aget_agent
+ from litellm.interactions.agents import alist as alist_agents
+ from litellm.interactions.agents import alist_versions as alist_agent_versions
+ from litellm.interactions.agents import create as create_agent
+ from litellm.interactions.agents import delete as delete_agent
+ from litellm.interactions.agents import get as get_agent
+ from litellm.interactions.agents import list as list_agents
+ from litellm.interactions.agents import list_versions as list_agent_versions
+
+ self.acreate_agent = self.factory_function(acreate_agent, call_type="acreate_agent")
+ self.create_agent = self.factory_function(create_agent, call_type="create_agent")
+ self.alist_agents = self.factory_function(alist_agents, call_type="alist_agents")
+ self.list_agents = self.factory_function(list_agents, call_type="list_agents")
+ self.aget_agent = self.factory_function(aget_agent, call_type="aget_agent")
+ self.get_agent = self.factory_function(get_agent, call_type="get_agent")
+ self.adelete_agent = self.factory_function(adelete_agent, call_type="adelete_agent")
+ self.delete_agent = self.factory_function(delete_agent, call_type="delete_agent")
+ self.alist_agent_versions = self.factory_function(alist_agent_versions, call_type="alist_agent_versions")
+ self.list_agent_versions = self.factory_function(list_agent_versions, call_type="list_agent_versions")
+
+ def _initialize_specialized_endpoints(self):
+ """Helper to initialize specialized router endpoints (vector store, OCR, search, video, container, skills, interactions)."""
+ self._initialize_vector_store_endpoints()
+ self._initialize_vector_store_file_endpoints()
+ self._initialize_google_genai_endpoints()
+ self._initialize_ocr_search_endpoints()
+ # Override vector store methods with router-aware implementations
+ self._override_vector_store_methods_for_router()
+ self._initialize_video_endpoints()
+ self._initialize_container_endpoints()
+ self._initialize_skills_endpoints()
+ self._initialize_interactions_endpoints()
+ self._initialize_managed_agents_endpoints()
+
+ def initialize_router_endpoints(self):
+ self._initialize_core_endpoints()
+ self._initialize_specialized_endpoints()
+
+ def validate_fallbacks(self, fallback_param: Sequence[Mapping[str, object]] | None):
+ """
+ Validate the fallbacks parameter.
+ """
+ if fallback_param is None:
+ return
+ for fallback_dict in fallback_param:
+ if not isinstance(fallback_dict, dict):
+ raise ValueError(f"Item '{fallback_dict}' is not a dictionary.")
+ if len(fallback_dict) != 1:
+ raise ValueError(
+ f"Dictionary '{fallback_dict}' must have exactly one key, but has {len(fallback_dict)} keys."
+ )
+ ((model_group, targets),) = fallback_dict.items()
+ if isinstance(targets, list) or check_non_standard_fallback_format(fallbacks=[fallback_dict]):
+ continue
+ raise TypeError(
+ f"Fallbacks for '{model_group}' must be a list of model names, got '{targets}'. "
+ f"Use {{'{model_group}': ['{targets}']}}"
+ )
+
+ def _add_encrypted_content_affinity_check(self, enable_global_affinity: bool) -> None:
+ def _move_before_deployment_affinity(
+ callback_list: list[_CallbackT],
+ callback_to_move: _CallbackT,
+ ) -> None:
+ if callback_to_move not in callback_list:
+ return
+ callback_list.remove(callback_to_move)
+ insert_index: Final = next(
+ (idx for idx, callback in enumerate(callback_list) if isinstance(callback, DeploymentAffinityCheck)),
+ len(callback_list),
+ )
+ callback_list.insert(insert_index, callback_to_move)
+
+ if enable_global_affinity or EncryptedContentAffinityCheck.has_model_group_affinity_enabled(
+ self.model_group_affinity_config
+ ):
+ if self.optional_callbacks is None:
+ self.optional_callbacks = []
+
+ existing_ec_callback: EncryptedContentAffinityCheck | None = None
+ for cb in self.optional_callbacks:
+ if isinstance(cb, EncryptedContentAffinityCheck):
+ existing_ec_callback = cb
+ break
+
+ if existing_ec_callback is not None:
+ existing_ec_callback.ctx = self._core_ctx
+ existing_ec_callback.enable_global_affinity = (
+ existing_ec_callback.enable_global_affinity or enable_global_affinity
+ )
+ existing_ec_callback.model_group_affinity_config = self.model_group_affinity_config or {}
+ ec_callback = existing_ec_callback
+ else:
+ ec_callback = EncryptedContentAffinityCheck(
+ ctx=self._core_ctx,
+ enable_global_affinity=enable_global_affinity,
+ model_group_affinity_config=self.model_group_affinity_config,
+ )
+ self.optional_callbacks.append(ec_callback)
+ self._host.callbacks.add_litellm_callback(ec_callback)
+
+ _move_before_deployment_affinity(self.optional_callbacks, ec_callback)
+ _move_before_deployment_affinity(self._host.callback_list("callbacks"), ec_callback)
+
+ def _ensure_deployment_affinity_callback(self) -> None:
+ """Register the DeploymentAffinityCheck callback (global flags all False) if absent.
+
+ Needed when nothing enabled a global affinity flag but affinity can still
+ activate per request: per-group `model_group_affinity_config` entries, or the
+ session-affinity marker a complexity router stamps at pre-routing time.
+ """
+ if any(isinstance(cb, DeploymentAffinityCheck) for cb in (self.optional_callbacks or [])):
+ return
+ if self.optional_callbacks is None:
+ self.optional_callbacks = []
+ affinity_callback: Final = DeploymentAffinityCheck(
+ cache=self._core_cache,
+ ttl_seconds=self.deployment_affinity_ttl_seconds,
+ enable_user_key_affinity=False,
+ enable_responses_api_affinity=False,
+ enable_session_id_affinity=False,
+ model_group_affinity_config=self.model_group_affinity_config,
+ is_priority_group=self._is_priority_routing_group,
+ session_affinity_repin_on_failover=self.session_affinity_repin_on_failover,
+ )
+ self.optional_callbacks.append(affinity_callback)
+ self._host.callbacks.add_litellm_callback(affinity_callback)
+
+ def add_optional_pre_call_checks(self, optional_pre_call_checks: OptionalPreCallChecks | None):
+ if optional_pre_call_checks is None:
+ return
+
+ # ---------------------------------------------------------------------
+ # Unified deployment affinity (session stickiness)
+ # ---------------------------------------------------------------------
+ enable_user_key_affinity: Final = "deployment_affinity" in optional_pre_call_checks
+ enable_responses_api_affinity: Final = "responses_api_deployment_check" in optional_pre_call_checks
+ enable_session_id_affinity: Final = "session_affinity" in optional_pre_call_checks
+ if enable_user_key_affinity or enable_responses_api_affinity or enable_session_id_affinity:
+ if self.optional_callbacks is None:
+ self.optional_callbacks = []
+
+ existing_affinity_callback: DeploymentAffinityCheck | None = None
+ for cb in self.optional_callbacks:
+ if isinstance(cb, DeploymentAffinityCheck):
+ existing_affinity_callback = cb
+ break
+
+ if existing_affinity_callback is not None:
+ existing_affinity_callback.enable_user_key_affinity = (
+ existing_affinity_callback.enable_user_key_affinity or enable_user_key_affinity
+ )
+ existing_affinity_callback.enable_responses_api_affinity = (
+ existing_affinity_callback.enable_responses_api_affinity or enable_responses_api_affinity
+ )
+ existing_affinity_callback.enable_session_id_affinity = (
+ existing_affinity_callback.enable_session_id_affinity or enable_session_id_affinity
+ )
+ existing_affinity_callback.ttl_seconds = self.deployment_affinity_ttl_seconds
+ if self.model_group_affinity_config:
+ existing_affinity_callback.model_group_affinity_config = self.model_group_affinity_config
+ else:
+ affinity_callback: Final = DeploymentAffinityCheck(
+ cache=self._core_cache,
+ ttl_seconds=self.deployment_affinity_ttl_seconds,
+ enable_user_key_affinity=enable_user_key_affinity,
+ enable_responses_api_affinity=enable_responses_api_affinity,
+ enable_session_id_affinity=enable_session_id_affinity,
+ model_group_affinity_config=self.model_group_affinity_config,
+ is_priority_group=self._is_priority_routing_group,
+ session_affinity_repin_on_failover=self.session_affinity_repin_on_failover,
+ )
+ self.optional_callbacks.append(affinity_callback)
+ self._host.callbacks.add_litellm_callback(affinity_callback)
+
+ # ---------------------------------------------------------------------
+ # Encrypted content affinity
+ # ---------------------------------------------------------------------
+ self._add_encrypted_content_affinity_check(
+ enable_global_affinity=("encrypted_content_affinity" in optional_pre_call_checks)
+ )
+
+ # ---------------------------------------------------------------------
+ # Remaining optional pre-call checks
+ # ---------------------------------------------------------------------
+ for pre_call_check in optional_pre_call_checks:
+ _callback: CustomLogger | None = None
+ if pre_call_check in (
+ "deployment_affinity",
+ "responses_api_deployment_check",
+ "session_affinity",
+ "encrypted_content_affinity",
+ ):
+ continue
+ if pre_call_check == "prompt_caching":
+ _callback = PromptCachingDeploymentCheck(
+ cache=self._core_prompt_caching_pin_cache, is_priority_group=self._is_priority_routing_group
+ )
+ elif pre_call_check == "router_budget_limiting":
+ if self._core_get_router_deployment_budget_limiter() is not None:
+ continue
+ _callback = RouterBudgetLimiting(
+ dual_cache=self._core_cache,
+ provider_budget_config=self._core_provider_budget_config,
+ model_list=self._core_model_list,
+ )
+ self.router_budget_logger = _callback
+ elif pre_call_check == "enforce_model_rate_limits":
+ _callback = ModelRateLimitingCheck(dual_cache=self._core_cache)
+
+ if _callback is None:
+ continue
+
+ if self.optional_callbacks is not None and any(
+ isinstance(callback, type(_callback)) for callback in self.optional_callbacks
+ ):
+ continue
+ if self.optional_callbacks is None:
+ self.optional_callbacks = []
+ self.optional_callbacks.append(_callback)
+ self._host.callbacks.add_litellm_callback(_callback)
+
+ def set_optional_pre_call_checks(self, optional_pre_call_checks: OptionalPreCallChecks | None) -> None:
+ if optional_pre_call_checks is None:
+ return
+ requested: Final = frozenset(optional_pre_call_checks)
+ for name, callback_cls in _RUNTIME_TOGGLEABLE_PRE_CALL_CHECKS.items():
+ if name not in requested:
+ self._remove_optional_callbacks_of_type(callback_cls)
+ self.add_optional_pre_call_checks(optional_pre_call_checks)
+
+ def _remove_optional_callbacks_of_type(self, callback_cls: type[CustomLogger]) -> None:
+ if self.optional_callbacks is None or not any(type(cb) is callback_cls for cb in self.optional_callbacks):
+ return
+ self.optional_callbacks = [cb for cb in self.optional_callbacks if type(cb) is not callback_cls]
+ if any(
+ router is not self and any(type(cb) is callback_cls for cb in (router.optional_callbacks or []))
+ for router in tuple(_live_routers)
+ ):
+ return
+ for cb in tuple(self._host.callback_list("callbacks")):
+ if type(cb) is callback_cls:
+ self._host.callbacks.remove_callback_from_list_by_object(
+ self._host.callback_list("callbacks"), cb, require_self=False
+ )
+
+ def print_deployment(self, deployment: dict):
+ """
+ returns a copy of the deployment with the api key masked
+
+ Only returns 2 characters of the api key and masks the rest with * (10 *).
+ """
+ try:
+ _deployment_copy: Final = copy.deepcopy(deployment)
+ litellm_params: Final[dict] = _deployment_copy["litellm_params"]
+
+ if self._host.dynamic().redact_user_api_key_info:
+ masker: Final = SensitiveDataMasker(visible_prefix=2, visible_suffix=0)
+ _deployment_copy["litellm_params"] = masker.mask_dict(litellm_params)
+ elif "api_key" in litellm_params:
+ litellm_params["api_key"] = litellm_params["api_key"][:2] + "*" * 10
+
+ return _deployment_copy
+ except Exception as e:
+ verbose_router_logger.debug("Error occurred while printing deployment - %s", e)
+ raise e
+
+ @staticmethod
+ def _deployment_params_with_request_reasoning_override(
+ deployment_params: Mapping[str, object], request_kwargs: Mapping[str, object]
+ ) -> dict[str, object]: # mutable-ok: litellm's request pipeline consumes a mutable kwargs mapping
+ """Return deployment params whose equivalent effort controls cannot outrank a request override.
+
+ Providers expose the same setting through several native carriers. A request-level
+ ``reasoning_effort`` is the portable override, so a deployment's ``thinking`` or nested
+ ``*.effort`` must not remain beside it and either win or trigger a conflicting-params 400.
+ Every changed mapping is copied so the Router's shared deployment config stays immutable.
+ """
+ sanitized: Final = dict(deployment_params)
+ if request_kwargs.get("reasoning_effort") is None:
+ return sanitized
+
+ sanitized.pop("thinking", None)
+ PythonCore._pop_effort_from_nested_carrier(sanitized, "output_config")
+ PythonCore._pop_effort_from_nested_carrier(sanitized, "reasoning")
+
+ extra_body: Final = sanitized.get("extra_body")
+ if isinstance(extra_body, Mapping):
+ sanitized_extra_body: Final = dict(extra_body)
+ sanitized_extra_body.pop("reasoning_effort", None)
+ sanitized_extra_body.pop("thinking", None)
+ PythonCore._pop_effort_from_nested_carrier(sanitized_extra_body, "output_config")
+ PythonCore._pop_effort_from_nested_carrier(sanitized_extra_body, "reasoning")
+ if sanitized_extra_body:
+ sanitized["extra_body"] = sanitized_extra_body
+ else:
+ sanitized.pop("extra_body", None)
+ return sanitized
+
+ @staticmethod
+ def _is_classifier_internal_call(kwargs: Mapping[str, object]) -> bool:
+ metadata: Final = kwargs.get("metadata")
+ litellm_metadata: Final = kwargs.get("litellm_metadata")
+ return any(
+ isinstance(candidate, Mapping)
+ and candidate.get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == AUTOROUTER_CLASSIFIER_CALL_ORIGIN
+ for candidate in (metadata, litellm_metadata)
+ )
+
+ def _drop_unsupported_classifier_reasoning_effort(
+ self,
+ deployment: DeploymentTypedDict,
+ model: str,
+ kwargs: dict[str, object], # mutable-ok: fallback must update the active request and its log body together
+ ) -> None:
+ """Let a classifier fallback without reasoning support remain a usable fallback.
+
+ The dashboard only offers explicitly advertised levels, but an existing config can outlive
+ a model change and fallbacks can target a different group. Unknown capability fails open;
+ only a provider that explicitly rejects the parameter has it removed.
+ """
+ if kwargs.get("reasoning_effort") is None or not self._is_classifier_internal_call(kwargs):
+ return
+ if self._deployment_accepts_param(deployment, model, "reasoning_effort"):
+ return
+ verbose_router_logger.warning(
+ "litellm.router.py: dropping classifier reasoning_effort for model=%s because the selected deployment does not support it",
+ model,
+ )
+ kwargs.pop("reasoning_effort", None)
+ proxy_server_request: Final = kwargs.get("proxy_server_request")
+ if not isinstance(proxy_server_request, dict):
+ return
+ body: Final = proxy_server_request.get("body")
+ if isinstance(body, dict):
+ body.pop("reasoning_effort", None)
+
+ ### COMPLETION, EMBEDDING, IMG GENERATION FUNCTIONS
+
+ def completion(self, model: str, messages: list[dict[str, str]], **kwargs) -> ModelResponse | CustomStreamWrapper:
+ """
+ Example usage:
+ response = router.completion(model="gpt-3.5-turbo", messages=[{"role": "user", "content": "Hey, how's it going?"}]
+ """
+ try:
+ verbose_router_logger.debug("router.completion(model=%s,..)", model)
+ kwargs["model"] = model
+ kwargs["messages"] = messages
+ kwargs["original_function"] = self._completion
+ controls: Final = per_request_fallback_controls(kwargs)
+ kwargs[MID_STREAM_FALLBACK_CONTROLS_KEY] = controls # rebind-ok: forwarded to every hop
+ self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
+
+ response: Final = self.function_with_fallbacks(**kwargs)
+ return response
+ except Exception as e:
+ raise e
+
+ def _completion(self, model: str, messages: list[dict[str, str]], **kwargs) -> ModelResponse | CustomStreamWrapper:
+ model_name = None
+ deployment = None
+ try:
+ controls: Final = kwargs.pop(MID_STREAM_FALLBACK_CONTROLS_KEY, None)
+ input_kwargs_for_streaming_fallback: Final = mid_stream_fallback_snapshot_kwargs(
+ model=model, controls=controls, kwargs=kwargs
+ )
+
+ # pick the one that is available (lowest TPM/RPM)
+ deployment = self.get_available_deployment(
+ model=model,
+ messages=messages,
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+ self._drop_unsupported_classifier_reasoning_effort(
+ deployment=cast(DeploymentTypedDict, deployment), # cast-ok: selection returns a router deployment
+ model=model,
+ kwargs=kwargs,
+ )
+ # Check for silent model experiment
+ # Make a local copy of litellm_params to avoid mutating the Router's state
+ litellm_params: Final = self._deployment_params_with_request_reasoning_override(
+ deployment["litellm_params"], kwargs
+ )
+ silent_model: Final = litellm_params.pop("silent_model", None)
+
+ for silent_target in _silent_experiment_targets(silent_model):
+ # Mirroring traffic to a secondary model
+ # Use threading.Thread (not ThreadPoolExecutor) - executor.submit()
+ # requires pickling args, which fails when kwargs contain unpicklable
+ # objects (e.g. _thread.RLock from OTEL spans, loggers) in deployment.
+ threading.Thread(
+ target=self._silent_experiment_completion,
+ args=(silent_target, messages),
+ kwargs=_silent_experiment_kwargs_snapshot(kwargs),
+ daemon=True,
+ ).start()
+
+ kwargs.setdefault("messages", messages)
+ self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, splits_stream_timeout=True)
+ kwargs.pop("silent_model", None) # Ensure it's not in kwargs either
+ model_name = litellm_params["model"]
+ potential_model_client: Final = self._get_client(deployment=deployment, kwargs=kwargs)
+ # check if provided keys == client keys #
+ dynamic_api_key: Final = kwargs.get("api_key", None)
+ if (
+ dynamic_api_key is not None
+ and potential_model_client is not None
+ and dynamic_api_key != potential_model_client.api_key
+ ):
+ model_client = None
+ else:
+ model_client = potential_model_client
+
+ ### DEPLOYMENT-SPECIFIC PRE-CALL CHECKS ### (e.g. update rpm pre-call. Raise error, if deployment over limit)
+ ## only run if model group given, not model id
+ if model in self._core_model_names or not self._core_has_model_id(model):
+ self.routing_strategy_pre_call_checks(deployment=deployment)
+
+ input_kwargs: Final = without_router_only_kwargs(
+ {
+ **litellm_params,
+ "messages": messages,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+ response: Final = self._host.invoke("completion")(**input_kwargs)
+ verbose_router_logger.info("litellm.completion(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+
+ ## CHECK CONTENT FILTER ERROR ##
+ if isinstance(response, ModelResponse):
+ _should_raise = self._should_raise_content_policy_error(model=model, response=response, kwargs=kwargs)
+ if _should_raise:
+ raise litellm_exceptions.ContentPolicyViolationError(
+ message="Response output was blocked.",
+ model=model,
+ llm_provider="",
+ )
+
+ if (
+ isinstance(response, CustomStreamWrapper)
+ and response.completion_stream is None
+ and response.make_call is not None
+ ):
+ response.fetch_sync_stream()
+
+ # Wrap streaming responses so MidStreamFallbackError (raised
+ # during iteration) triggers the Router's fallback chain.
+ if isinstance(response, CustomStreamWrapper):
+ return self._completion_streaming_iterator(
+ model_response=response,
+ messages=messages,
+ initial_kwargs=input_kwargs_for_streaming_fallback,
+ first_chunk=_read_first_chunk(response),
+ )
+
+ return response
+ except Exception as e:
+ verbose_router_logger.info("litellm.completion(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
+ # Set per-deployment num_retries on exception for retry logic
+ if deployment is not None:
+ self._set_deployment_num_retries_on_exception(e, deployment)
+ self._stamp_failed_deployment_id_with_effective_model_info(e, deployment, kwargs)
+ raise e
+
+ def _get_silent_experiment_kwargs(self, **kwargs) -> dict:
+ """
+ Prepare kwargs for a silent experiment by ensuring isolation from the primary call.
+
+ Guarantee metadata isolation: safe_deep_copy falls back to the original
+ reference when deepcopy fails (e.g. metadata contains UserAPIKeyAuth with
+ parent_otel_span — an OTel Span that is not deepcopy-able). Force a shallow
+ copy of the metadata dict so mutations (model_group, is_silent_experiment)
+ never corrupt the main call's metadata.
+ """
+ from litellm.litellm_core_utils.core_helpers import safe_deep_copy
+
+ silent_kwargs: Final = safe_deep_copy(kwargs)
+
+ # safe_deep_copy may fall back to the original metadata reference when
+ # deepcopy fails (UserAPIKeyAuth.parent_otel_span is not deepcopy-able).
+ # Detect this via identity check and force a shallow copy so that setting
+ # model_group / is_silent_experiment on the silent dict doesn't corrupt
+ # the primary call's metadata.
+ original_metadata: Final = kwargs.get("metadata")
+ if original_metadata is not None and silent_kwargs.get("metadata") is original_metadata:
+ silent_kwargs["metadata"] = dict(original_metadata)
+
+ if "metadata" not in silent_kwargs:
+ silent_kwargs["metadata"] = {}
+
+ # OTel spans are not safe to use across event loops. The silent
+ # experiment runs in a new event loop, so strip the span to prevent
+ # cross-loop tracing races or span corruption.
+ silent_kwargs["metadata"].pop("litellm_parent_otel_span", None)
+
+ silent_kwargs["metadata"]["is_silent_experiment"] = True
+
+ # Pop logging objects and call IDs to ensure a fresh logging context
+ # This prevents collisions in the Proxy's database (spend_logs)
+ silent_kwargs.pop("litellm_call_id", None)
+ silent_kwargs.pop("litellm_logging_obj", None)
+ silent_kwargs.pop("standard_logging_object", None)
+ # DON'T pop proxy_server_request — it's needed for spend log metadata
+
+ return silent_kwargs
+
+ async def _run_silent_experiment(
+ self, silent_model: str, messages: Sequence[Mapping[str, str]], silent_kwargs: Mapping[str, object]
+ ) -> None:
+ remaining_kwargs: Final = MappingProxyType(
+ {key: value for key, value in silent_kwargs.items() if key != "stream"}
+ )
+ with mirrored_call():
+ response: Final = await self.acompletion(
+ model=silent_model,
+ messages=cast(list[AllMessageValues], messages),
+ stream=bool(silent_kwargs.get("stream", False)),
+ **remaining_kwargs,
+ )
+ if not isinstance(response, CustomStreamWrapper):
+ return
+ async for _ in response:
+ pass
+
+ def _silent_experiment_completion(self, silent_model: str, messages: Sequence[Mapping[str, str]], **kwargs):
+ """
+ Run a silent experiment in the background (thread).
+ """
+ try:
+ # Prevent infinite recursion if silent model also has a silent model
+ if kwargs.get("metadata", {}).get("is_silent_experiment", False):
+ return
+
+ messages = copy.deepcopy(messages)
+
+ verbose_router_logger.info("Starting silent experiment for model %s", silent_model)
+
+ silent_kwargs: Final = self._get_silent_experiment_kwargs(**kwargs)
+
+ # Override model_group to correctly attribute metrics to the silent model
+ silent_kwargs["metadata"]["model_group"] = silent_model
+
+ # Create a new event loop for this thread so that async success
+ # callbacks (e.g. _ProxyDBLogger) can schedule and run DB writes.
+ loop: Final = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ try:
+
+ async def _run_silent_completion():
+ await self._run_silent_experiment(silent_model, messages, silent_kwargs)
+ # Drain any fire-and-forget tasks (e.g. alerting hooks)
+ # scheduled via asyncio.create_task during acompletion.
+ pending: Final = asyncio.all_tasks()
+ current: Final = asyncio.current_task()
+ if current is not None:
+ pending.discard(current)
+ if pending:
+ await asyncio.gather(*pending, return_exceptions=True)
+
+ loop.run_until_complete(_run_silent_completion())
+ finally:
+ loop.close()
+ except Exception as e:
+ verbose_router_logger.error("Silent experiment failed for model %s: %s", silent_model, e)
+
+ # fmt: off
+
+ @overload
+ async def acompletion(
+ self, model: str, messages: list[AllMessageValues], stream: Literal[True], **kwargs
+ ) -> CustomStreamWrapper:
+ ...
+
+ @overload
+ async def acompletion(
+ self, model: str, messages: list[AllMessageValues], stream: Literal[False] = False, **kwargs
+ ) -> ModelResponse:
+ ...
+
+ @overload
+ async def acompletion(
+ self, model: str, messages: list[AllMessageValues], stream: Literal[True, False] = False, **kwargs
+ ) -> CustomStreamWrapper | ModelResponse:
+ ...
+
+ # fmt: on
+
+ # The actual implementation of the function
+ async def acompletion(
+ self,
+ model: str,
+ messages: list[AllMessageValues],
+ stream: bool = False,
+ **kwargs,
+ ):
+ try:
+ kwargs["model"] = model
+ kwargs["messages"] = messages
+ kwargs["stream"] = stream
+ kwargs["original_function"] = self._acompletion
+
+ self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
+ start_time: Final = time.time()
+ is_prompt_management_model: Final = self._is_prompt_management_model(model)
+ if not is_prompt_management_model:
+ controls: Final = per_request_fallback_controls(kwargs)
+ kwargs[MID_STREAM_FALLBACK_CONTROLS_KEY] = controls # rebind-ok: forwarded to every hop
+ request_priority, request_kwargs = self._core_split_request_priority(model=model, kwargs=kwargs)
+ original_function: Final[Callable[..., Awaitable[object]]] = (
+ self._prompt_management_acompletion(model=model, messages=messages)
+ if is_prompt_management_model
+ else self.async_function_with_fallbacks
+ )
+ if request_priority is None:
+ response = await original_function(**request_kwargs)
+ else:
+ response = await self._schedule_factory(
+ model=model,
+ priority=request_priority,
+ original_function=original_function,
+ args=(),
+ kwargs=request_kwargs,
+ )
+ end_time: Final = time.time()
+ _duration: Final = end_time - start_time
+ asyncio.create_task(
+ self.service_logger_obj.async_service_success_hook(
+ service=ServiceTypes.ROUTER,
+ duration=_duration,
+ call_type="acompletion",
+ start_time=start_time,
+ end_time=end_time,
+ parent_otel_span=get_parent_otel_span_from_kwargs(kwargs),
+ )
+ )
+
+ return response
+ except Exception as e:
+ asyncio.create_task(
+ send_llm_exception_alert(
+ ctx=self._core_ctx,
+ request_kwargs=kwargs,
+ error_traceback_str=traceback.format_exc(),
+ original_exception=e,
+ )
+ )
+ raise e
+
+ @staticmethod
+ def _combine_fallback_usage(
+ fallback_item: ModelResponseStream,
+ complete_response_object_usage: Usage | None,
+ ) -> None:
+ """Merge partial-stream usage with fallback-stream usage on the chunk."""
+ from litellm.cost_calculator import BaseTokenUsageProcessor
+
+ usage: Final = cast(Usage | None, getattr(fallback_item, "usage", None))
+ usage_objects: Final = [usage] if usage is not None else []
+ if complete_response_object_usage is not None:
+ usage_objects.append(complete_response_object_usage)
+ combined_usage: Final = BaseTokenUsageProcessor.combine_usage_objects(usage_objects=usage_objects)
+ setattr(fallback_item, "usage", combined_usage)
+
+ @staticmethod
+ def _price_cut_partial(
+ cut_stream: CustomStreamWrapper,
+ partial_response: ModelResponse | TextCompletionResponse | None,
+ ) -> "ContinuedPartial | None":
+ """Price the cut stream's partial, plus any partial it inherited, before a fallback can reuse its logging."""
+ from litellm.cost_calculator import BaseTokenUsageProcessor
+
+ cut_logging: Final = cut_stream.logging_obj
+ partial_usage: Final = getattr(partial_response, "usage", None)
+ if partial_response is None or not isinstance(partial_usage, Usage):
+ return None
+ inherited_usage: Final = cut_logging.model_call_details.get("continued_partial_usage")
+ inherited_cost: Final = cut_logging.model_call_details.get("continued_partial_cost")
+ inherited_id: Final = cut_logging.model_call_details.get("continued_response_id")
+ priced_partial: Final = partial_response.model_copy(
+ update={"model": cut_stream.model} if cut_stream.model else {}
+ )
+ priced_partial._hidden_params = { # pyright: ignore[reportPrivateUsage] # no public accessor
+ key: value for key, value in get_hidden_params_dict(partial_response).items() if key != "response_cost"
+ }
+ if LiteLLMLogging.usage_capture_callback is not None:
+ from litellm._internal_context import internal_sub_call
+
+ with internal_sub_call(cut_logging.litellm_call_id):
+ cut_logging.capture_usage_before_logging(priced_partial)
+ return ContinuedPartial(
+ usage=BaseTokenUsageProcessor.combine_usage_objects(
+ usage_objects=[partial_usage, inherited_usage]
+ if isinstance(inherited_usage, Usage)
+ else [partial_usage]
+ ),
+ response_cost=(cut_logging.response_cost_calculator(result=priced_partial) or 0.0)
+ + (inherited_cost if isinstance(inherited_cost, float) else 0.0),
+ response_id=inherited_id if isinstance(inherited_id, str) else partial_response.id,
+ )
+
+ @staticmethod
+ def _carry_partial_spend_to_fallback(fallback_response: object, cut_partial: "ContinuedPartial | None") -> None:
+ """Bill what the cut legs consumed on the continuation's success log."""
+ fallback_logging: Final = getattr(fallback_response, "logging_obj", None)
+ if cut_partial is None or not isinstance(fallback_logging, LiteLLMLogging):
+ return
+ fallback_logging.record_continued_partial(
+ usage=cut_partial.usage, response_cost=cut_partial.response_cost, response_id=cut_partial.response_id
+ )
+
+ @staticmethod
+ def _prepare_fallback_hidden_params(
+ fallback_response: object,
+ ) -> tuple[dict[str, object], dict[str, object]]:
+ fallback_hidden_params: Final = get_hidden_params_dict(fallback_response)
+ fallback_headers: Final = fallback_hidden_params.get("additional_headers")
+ if not isinstance(fallback_headers, dict):
+ return fallback_hidden_params, {}
+ return fallback_hidden_params, cast("dict[str, object]", fallback_headers)
+
+ @staticmethod
+ def _adopt_fallback_response_headers(
+ wrapper_ref: "weakref.ref[FallbackAwareStreamWrapper]",
+ fallback_response: object,
+ ) -> tuple[dict[str, object], dict[str, object]]:
+ """Repoint the wrapper at `fallback_response`, returning its prepared hidden params."""
+ prepared: Final = PythonCore._prepare_fallback_hidden_params(fallback_response)
+ adopting_wrapper: Final = wrapper_ref()
+ if adopting_wrapper is not None:
+ adopting_wrapper.adopt_fallback_response_headers(fallback_response, prepared)
+ return prepared
+
+ @staticmethod
+ def _apply_fallback_hidden_params_to_item(
+ fallback_item: object,
+ prepared_fallback_hidden_params: tuple[dict[str, object], dict[str, object]],
+ ) -> None:
+ if fallback_item is None or not hasattr(fallback_item, _HIDDEN_PARAMS_ATTR):
+ return
+
+ fallback_hidden_params, fallback_headers = prepared_fallback_hidden_params
+ item_hidden_params: Final = get_hidden_params_dict(fallback_item)
+ item_headers = item_hidden_params.get("additional_headers")
+ if not isinstance(item_headers, dict):
+ item_headers = {}
+
+ set_hidden_params(
+ fallback_item,
+ {
+ **item_hidden_params,
+ **fallback_hidden_params,
+ "additional_headers": {**item_headers, **fallback_headers},
+ },
+ )
+
+ async def _acompletion_streaming_iterator(
+ self,
+ model_response: CustomStreamWrapper,
+ messages: list[dict[str, str]],
+ initial_kwargs: dict[str, Any],
+ deployment_slot: contextlib.AsyncExitStack | None = None,
+ first_chunk: _FirstChunk[AsyncIterator[object]] | None = None,
+ ) -> CustomStreamWrapper:
+ """
+ Helper to iterate over a streaming response.
+
+ Catches errors for fallbacks using the router's fallback system
+
+ `deployment_slot` holds the deployment's max_parallel_requests semaphore; it is
+ released when the stream is exhausted, closed, or falls back to another deployment
+
+ `first_chunk` carries the chunk _acompletion already read (None: iterate from the start)
+ """
+ from litellm.exceptions import MidStreamFallbackError
+
+ held_slot: Final = deployment_slot if deployment_slot is not None else contextlib.AsyncExitStack()
+
+ class FallbackStreamWrapper(FallbackAwareStreamWrapper):
+ def __init__(self, async_generator: AsyncGenerator):
+ # Copy attributes from the original model_response
+ super().__init__(
+ completion_stream=async_generator,
+ model=model_response.model,
+ custom_llm_provider=model_response.custom_llm_provider,
+ logging_obj=model_response.logging_obj,
+ _response_headers=getattr(model_response, "_response_headers", None),
+ )
+ self._async_generator = async_generator
+ inner_chunks: Final[object] = getattr(model_response, "chunks", None)
+ if isinstance(inner_chunks, list):
+ self.chunks = inner_chunks
+ # Preserve hidden params (including litellm_overhead_time_ms) from original response
+ model_response_hidden_params: Final = get_hidden_params(model_response)
+ if model_response_hidden_params is not None:
+ self._hidden_params = dict(model_response_hidden_params)
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ return await self._async_generator.__anext__()
+
+ async def close_model_response() -> None:
+ if not hasattr(model_response, "aclose"):
+ return
+ try:
+ await model_response.aclose()
+ except BaseException as e:
+ verbose_router_logger.debug(
+ "stream_with_fallbacks: error closing model_response: %s",
+ e,
+ )
+
+ async def stream_with_fallbacks():
+ fallback_response = None # Track for cleanup in finally
+ try:
+ async for item in _achunks_with_first(model_response, first_chunk):
+ yield item
+ except (MidStreamFallbackError, litellm_exceptions.ContextWindowExceededError) as stream_error:
+ e: Final = (
+ _context_window_stream_fallback_error(
+ stream_error,
+ getattr(model_response, "model", ""),
+ model_response.custom_llm_provider or "",
+ bool(getattr(model_response, "response_uptil_now", ""))
+ or _stream_chunks_have_generated_content(model_response.chunks),
+ )
+ if isinstance(stream_error, litellm_exceptions.ContextWindowExceededError)
+ or isinstance(stream_error.original_exception, litellm_exceptions.ContextWindowExceededError)
+ else stream_error
+ )
+ with anyio.CancelScope(shield=True):
+ await close_model_response()
+ await held_slot.aclose()
+ committed: Final = bool(
+ not e.is_pre_first_chunk
+ and (e.generated_content or _stream_chunks_have_generated_content(model_response.chunks))
+ )
+ cut_hidden_params: Final = get_hidden_params(model_response) or {}
+ continue_after_content: Final = committed and self._mid_stream_continuation_eligible(
+ e=e, request_kwargs=initial_kwargs, cut_deployment_id=cut_hidden_params.get("model_id")
+ )
+ if committed and not continue_after_content:
+ self._raise_original_mid_stream_error(e)
+
+ from litellm.main import stream_chunk_builder
+
+ complete_response_object: Final = stream_chunk_builder(
+ chunks=model_response.chunks, messages=messages if continue_after_content else None
+ )
+ complete_response_object_usage: Final = cast(
+ Usage | None,
+ getattr(complete_response_object, "usage", None),
+ )
+ try:
+ # Use the router's fallback system
+ model_group: Final = cast(str, initial_kwargs.get("model"))
+ fallbacks: Final[list | None] = initial_kwargs.get("fallbacks", self._core_fallbacks)
+ context_window_fallbacks: Final[list | None] = initial_kwargs.get(
+ "context_window_fallbacks", self._core_context_window_fallbacks
+ )
+ content_policy_fallbacks: Final[list | None] = initial_kwargs.get(
+ "content_policy_fallbacks", self._core_content_policy_fallbacks
+ )
+ initial_kwargs["original_function"] = self._acompletion
+ continuation_messages: Final = (
+ self._build_completion_continuation_input(messages, e.generated_content)
+ if continue_after_content
+ else None
+ )
+ continuation_output: Final = (
+ ContinuationOutput(
+ e.generated_content,
+ continuation_messages[-1],
+ model_group,
+ complete_response_object,
+ checks_post_call_rules=initial_kwargs.get(MID_STREAM_CONTINUATION_KWARG)
+ is not MID_STREAM_CONTINUATION_MARKER,
+ )
+ if continuation_messages is not None
+ else None
+ )
+ cut_partial: Final = (
+ PythonCore._price_cut_partial(model_response, complete_response_object)
+ if continuation_output is not None
+ else None
+ )
+ if continue_after_content:
+ emitted_tokens: Final = int(
+ getattr(complete_response_object_usage, "completion_tokens", 0) or 0
+ )
+ reduced_ceilings: Final = continuation_output_ceilings(initial_kwargs, emitted_tokens)
+ if reduced_ceilings is None or continuation_messages is None:
+ self._raise_original_mid_stream_error(e)
+ await model_response.wait_for_failure_logging(timeout=MID_STREAM_FAILURE_LOG_WAIT_SECONDS)
+ initial_kwargs.update(reduced_ceilings)
+ initial_kwargs["messages"] = continuation_messages
+ initial_kwargs[MID_STREAM_CONTINUATION_KWARG] = MID_STREAM_CONTINUATION_MARKER
+ else:
+ initial_kwargs["messages"] = messages
+ self._update_kwargs_before_fallbacks(model=model_group, kwargs=initial_kwargs)
+ fallback_response = await self.async_function_with_fallbacks_common_utils(
+ e=e.original_exception
+ if isinstance(e.original_exception, litellm_exceptions.ContextWindowExceededError)
+ else e,
+ disable_fallbacks=fallbacks_disabled_for_request(initial_kwargs),
+ fallbacks=fallbacks,
+ context_window_fallbacks=context_window_fallbacks,
+ content_policy_fallbacks=content_policy_fallbacks,
+ model_group=model_group,
+ args=(),
+ kwargs=initial_kwargs,
+ include_fallback_errors=initial_kwargs.get("include_fallback_errors", False) is True,
+ )
+
+ # If fallback returns a streaming response, iterate over it
+ if hasattr(fallback_response, "__aiter__"):
+ PythonCore._carry_partial_spend_to_fallback(fallback_response, cut_partial)
+ prepared_fallback_hidden_params = PythonCore._adopt_fallback_response_headers(
+ wrapper_ref, fallback_response
+ )
+ fallback_headers_are_settled = False
+ fallback_items: Final = (
+ continuation_output.splice(fallback_response)
+ if continuation_output is not None
+ else fallback_response
+ )
+ async for fallback_item in fallback_items:
+ if not fallback_headers_are_settled:
+ fallback_headers_are_settled = True
+ # a fallback that failed over again only repoints itself once it yields
+ prepared_fallback_hidden_params = PythonCore._adopt_fallback_response_headers(
+ wrapper_ref, fallback_response
+ )
+ PythonCore._apply_fallback_hidden_params_to_item(
+ fallback_item, prepared_fallback_hidden_params
+ )
+ if (
+ fallback_item
+ and isinstance(fallback_item, ModelResponseStream)
+ and hasattr(fallback_item, "usage")
+ ):
+ self._combine_fallback_usage(
+ fallback_item,
+ complete_response_object_usage if continuation_output is not None else None,
+ )
+ yield fallback_item
+ else:
+ # If fallback returns a non-streaming response, yield None
+ yield None
+
+ except Exception as fallback_error:
+ # If fallback also fails, log and re-raise original error
+ verbose_router_logger.error("Fallback also failed: %s", fallback_error)
+ # No fallback handled the mid-stream error, so surface the
+ # real provider exception (e.g. RateLimitError) instead of
+ # leaking the internal MidStreamFallbackError to the client
+ if (
+ isinstance(fallback_error, MidStreamFallbackError)
+ and fallback_error.original_exception is not None
+ ):
+ raise fallback_error.original_exception from fallback_error
+ raise fallback_error
+ finally:
+ # Close the underlying streams to release HTTP connections
+ # back to the connection pool when the generator is closed
+ # (e.g. on client disconnect).
+ # Shield from anyio cancellation so the awaits can complete.
+ with anyio.CancelScope(shield=True):
+ await close_model_response()
+ await held_slot.aclose()
+ if fallback_response is not None and hasattr(fallback_response, "aclose"):
+ try:
+ await fallback_response.aclose()
+ except BaseException as close_error:
+ verbose_router_logger.debug(
+ "stream_with_fallbacks: error closing fallback_response: %s",
+ close_error,
+ )
+
+ wrapped_response: Final = FallbackStreamWrapper(stream_with_fallbacks())
+ # weak, so the generator closing over it does not keep the wrapper out of
+ # refcount teardown and delay the `finally` that releases the deployment slot
+ wrapper_ref: Final[weakref.ref[FallbackAwareStreamWrapper]] = weakref.ref(wrapped_response)
+ return wrapped_response
+
+ @staticmethod
+ def _extract_partial_responses_usage(
+ source_iterator: "BaseResponsesAPIStreamingIterator",
+ ) -> Optional["ResponseAPIUsage"]:
+ """
+ Best-effort: pull partial token usage from a Responses-API streaming
+ iterator that errored mid-stream, normalized to ResponseAPIUsage so
+ the caller can combine without crossing token-naming conventions.
+
+ Two sources, in priority order:
+ 1. The bridge path (LiteLLMCompletionStreamingIterator) accumulates
+ chat-completion chunks while streaming — feed them through
+ stream_chunk_builder to recover chat Usage, then translate
+ (prompt_tokens → input_tokens, completion_tokens → output_tokens).
+ 2. The native path (ResponsesAPIStreamingIterator) only has a
+ completed_response object if the stream reached
+ RESPONSE_COMPLETED before erroring — uncommon mid-stream but
+ worth checking. Already ResponseAPIUsage-shaped.
+
+ Returns None when no partial usage is recoverable.
+ """
+ from litellm.responses.litellm_completion_transformation.streaming_iterator import (
+ LiteLLMCompletionStreamingIterator,
+ )
+ from litellm.types.llms.openai import (
+ ResponseAPIUsage,
+ ResponseCompletedEvent,
+ ResponseFailedEvent,
+ ResponseIncompleteEvent,
+ )
+
+ # Bridge subclass is the only iterator that accumulates chat-completion
+ # chunks. isinstance narrows the type so we can read the attribute
+ # directly instead of getattr-ing on the base class.
+ if isinstance(source_iterator, LiteLLMCompletionStreamingIterator):
+ chunks: Final = source_iterator.collected_chat_completion_chunks
+ if chunks:
+ try:
+ from litellm.main import stream_chunk_builder
+
+ built: Final = stream_chunk_builder(chunks=chunks)
+ # stream_chunk_builder returns ModelResponse |
+ # TextCompletionResponse | None. ModelResponse sets .usage
+ # in __init__ rather than declaring it as a class field, so
+ # static narrowing doesn't expose it. Mirror the sync path
+ # (_completion_streaming_iterator) and pull via getattr.
+ chat: Final[object | None] = getattr(built, "usage", None) if built is not None else None
+ if chat is not None:
+ # getattr-with-default because the test path may
+ # substitute a SimpleNamespace lacking some fields;
+ # real Usage instances always have them.
+ prompt: Final = int(getattr(chat, "prompt_tokens", 0) or 0)
+ completion: Final = int(getattr(chat, "completion_tokens", 0) or 0)
+ total: Final = int(getattr(chat, "total_tokens", prompt + completion) or (prompt + completion))
+ return ResponseAPIUsage(
+ input_tokens=prompt,
+ output_tokens=completion,
+ total_tokens=total,
+ )
+ except Exception:
+ # Builder is best-effort — fall through to native path.
+ pass
+
+ # Native path: completed_response is set only if RESPONSE_COMPLETED
+ # arrived before the error (uncommon mid-stream but worth checking).
+ # Already ResponseAPIUsage-shaped — return as-is.
+ completed: Final = source_iterator.completed_response
+ if isinstance(
+ completed,
+ (ResponseCompletedEvent, ResponseFailedEvent, ResponseIncompleteEvent),
+ ):
+ return completed.response.usage
+ return None
+
+ @staticmethod
+ def _combine_responses_fallback_usage(
+ fallback_item: "BaseLiteLLMOpenAIResponseObject",
+ partial_usage: "ResponseAPIUsage",
+ ) -> None:
+ """
+ Merge partial-stream usage with fallback-stream usage on a
+ Responses-API streaming event.
+
+ Only mutates events that carry a `response` with a `usage` field
+ (response.completed / response.failed / response.incomplete). Other
+ events pass through unchanged.
+
+ Both inputs are ResponseAPIUsage-shaped (see
+ _extract_partial_responses_usage which normalizes the bridge path),
+ so we can sum input_tokens / output_tokens / total_tokens directly
+ and produce a clean ResponseAPIUsage — no token-naming split, no
+ setattr bypass.
+ """
+ from litellm.types.llms.openai import (
+ ResponseAPIUsage,
+ ResponseCompletedEvent,
+ ResponseFailedEvent,
+ ResponseIncompleteEvent,
+ )
+
+ if not isinstance(
+ fallback_item,
+ (ResponseCompletedEvent, ResponseFailedEvent, ResponseIncompleteEvent),
+ ):
+ return
+ response: Final = fallback_item.response
+ if response.usage is None:
+ return
+
+ fb: Final = response.usage
+ response.usage = ResponseAPIUsage(
+ input_tokens=(partial_usage.input_tokens or 0) + (fb.input_tokens or 0),
+ output_tokens=(partial_usage.output_tokens or 0) + (fb.output_tokens or 0),
+ total_tokens=(partial_usage.total_tokens or 0) + (fb.total_tokens or 0),
+ )
+
+ @staticmethod
+ def _raise_original_mid_stream_error(e: "MidStreamFallbackError") -> NoReturn:
+ """Surface the provider error the stream wrapper carried instead of the internal MidStreamFallbackError."""
+ if e.original_exception is not None:
+ raise e.original_exception from e
+ raise e
+
+ def _active_default_litellm_params(self) -> Mapping[str, object]:
+ default_params: Final = _REQUEST_KWARGS_ADAPTER.validate_python(self._core_default_litellm_params)
+ return {key: value for key, value in default_params.items() if value is not None}
+
+ def _params_sent_to_deployment(
+ self, request_kwargs: Mapping[str, object], deployment: Deployment | None
+ ) -> Mapping[str, object]:
+ """The request as the deployment received it, merged in the order _update_kwargs_with_deployment applies."""
+ deployment_params: Final = deployment.litellm_params.model_dump(exclude_none=True) if deployment else {}
+ defaults: Final = self._active_default_litellm_params()
+ deployment_tool_choice: Final = (
+ {"tool_choice": deployment_params["tool_choice"]}
+ if deployment_params.get("tool_choice") is not None
+ else {}
+ )
+ return MappingProxyType({**deployment_params, **defaults, **deployment_tool_choice, **request_kwargs})
+
+ def _names_deployment_that_cannot_continue(self, target: object) -> bool:
+ name: Final = fallback_target_name(target)
+ if name is None or name in self._core_model_names or not self._core_has_model_id(name):
+ return False
+ deployment: Final = self._core_get_deployment(model_id=name)
+ return deployment is not None and not deployment_can_continue(deployment.model_dump(exclude_none=True))
+
+ def _mid_stream_continuation_eligible(
+ self,
+ e: "MidStreamFallbackError",
+ request_kwargs: Mapping[str, object],
+ cut_deployment_id: object = None,
+ ) -> bool:
+ """Whether a stream that broke after plain assistant text may continue via prefill."""
+ if not self.enable_mid_stream_fallback_continuation:
+ return False
+ if not e.generated_content or e.emitted_disqualifying_content:
+ return False
+ if isinstance(e.original_exception, litellm_exceptions.ContentPolicyViolationError):
+ return False
+ cut_deployment: Final = (
+ self._core_get_deployment(model_id=cut_deployment_id) if isinstance(cut_deployment_id, str) else None
+ )
+ if isinstance(cut_deployment_id, str) and cut_deployment is None:
+ return False
+ if constrains_continuation(self._params_sent_to_deployment(request_kwargs, cut_deployment)):
+ return False
+ requested_group: Final = request_kwargs.get("model")
+ lookup_groups: Final = fallback_lookup_groups(
+ request_kwargs, requested_group if isinstance(requested_group, str) else None
+ )
+ targets: Final = continuation_fallback_targets(
+ request_kwargs.get("fallbacks", self._core_fallbacks), lookup_groups
+ )
+ if any(fallback_target_overrides_request_params(target) for target in targets):
+ return False
+ if any(self._names_deployment_that_cannot_continue(target) for target in targets):
+ return False
+ return not any(
+ isinstance(callback, CustomGuardrail)
+ and callback._event_hook_is_event_type( # pyright: ignore[reportPrivateUsage] # the only check of a guardrail's hooks
+ GuardrailEventHooks.post_call
+ )
+ for callback in self._host.callback_list("callbacks")
+ )
+
+ @staticmethod
+ def _build_completion_continuation_input(
+ messages: Sequence[Mapping[str, object]],
+ generated_content: str,
+ ) -> Sequence[Mapping[str, object]] | None:
+ """Append the partial output as an assistant prefill, or extend a trailing `prefix: True` assistant turn in
+ place so the request never ends in two assistant messages. None when the request ends in a plain assistant
+ turn, in a prefill turn whose content has a shape this cannot extend, or when the cut came mid-echo of the
+ prefill."""
+ last: Final = messages[-1] if messages else None
+ if last is None or last.get("role") != "assistant":
+ return [*messages, {"role": "assistant", "content": generated_content, "prefix": True}]
+ if last.get("prefix") is not True:
+ return None
+ new_text: Final = continued_text(last, generated_content)
+ if new_text is None:
+ return None
+ content: Final = last.get("content")
+ if content is None or isinstance(content, str):
+ return [*messages[:-1], {**last, "content": f"{content or ''}{new_text}", "prefix": True}]
+ try:
+ blocks: Final = _CONTENT_BLOCKS_ADAPTER.validate_python(content)
+ except ValidationError:
+ return None
+ extended: Final = _extend_trailing_text_block(blocks, new_text)
+ return [*messages[:-1], {**last, "content": extended, "prefix": True}]
+
+ async def _aresponses_streaming_iterator(
+ self,
+ response: "BaseResponsesAPIStreamingIterator",
+ initial_kwargs: dict[str, object],
+ ) -> "BaseResponsesAPIStreamingIterator":
+ """
+ Wrap a Responses-API streaming iterator so MidStreamFallbackError
+ triggers the Router's fallback chain (parity with
+ _acompletion_streaming_iterator for the chat-completions path).
+
+ The Responses-API streaming path goes through
+ _ageneric_api_call_with_fallbacks rather than _acompletion, so the
+ returned iterator is never wrapped by the chat completions
+ fallback handler. Without this wrapper, MidStreamFallbackError
+ raised mid-stream from the underlying CustomStreamWrapper (used by
+ LiteLLMCompletionStreamingIterator when the Responses API is
+ served via the completion bridge) propagates unhandled and the
+ configured cross-provider fallback never fires.
+
+ Full parity with the chat-completions path:
+ - Pre-first-chunk: retry with the original input unchanged.
+ - Partial content: continue from the generated text, as an
+ assistant prefill on a fallback that takes one and as assistant
+ history plus a user request to continue on any other. A request
+ whose output is constrained (a JSON schema, a forced tool), or a
+ stream that began a tool call, is not continued, and the primary's
+ error surfaces instead.
+ - Usage combining: merge partial-stream usage onto the fallback's
+ response.completed event so accounting reflects both attempts.
+ - Stream cleanup: shielded aclose() on both source and fallback
+ iterators on terminate.
+ """
+ from litellm.exceptions import MidStreamFallbackError
+ from litellm.responses.streaming_iterator import (
+ BaseResponsesAPIStreamingIterator,
+ get_openai_response_types,
+ )
+
+ source_iterator: Final = response
+
+ # Pre-resolve the set of terminal stream event types so the
+ # per-chunk type check inside FallbackResponsesStreamWrapper
+ # stays cheap; mirrors the source-iterator filter at
+ # responses/streaming_iterator.py:243-247.
+ _openai_types: Final = get_openai_response_types()
+ _RESPONSES_TERMINAL_EVENT_TYPES: Final = (
+ _openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED,
+ _openai_types.ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE,
+ _openai_types.ResponsesAPIStreamEvents.RESPONSE_FAILED,
+ )
+
+ class FallbackResponsesStreamWrapper(BaseResponsesAPIStreamingIterator):
+ """
+ Subclasses BaseResponsesAPIStreamingIterator only for isinstance
+ compatibility (proxy + interactions code paths check the type).
+ Bypasses the parent constructor and delegates iteration to an
+ async generator.
+ """
+
+ fallback_headers_adopted: bool = False
+
+ def __init__(self, async_generator: AsyncGenerator):
+ import time
+ from datetime import datetime
+
+ self._async_generator = async_generator
+ self._billing_source: object = source_iterator
+ # Mirror every attribute BaseResponsesAPIStreamingIterator.__init__
+ # would have set. The wrapper bypasses super().__init__ (it has no
+ # httpx.Response of its own and no provider config to drive), so
+ # we copy from source_iterator where applicable and use safe
+ # defaults elsewhere. This keeps inherited methods (e.g.
+ # _check_max_streaming_duration, _handle_failure) safe to call.
+ #
+ # The bridge path (LiteLLMCompletionStreamingIterator used by
+ # Anthropic/Bedrock/Vertex) does not call super().__init__ and
+ # is missing many of these attributes — use getattr fallbacks
+ # so wrapper construction never raises AttributeError. The
+ # bridge stores the logging object as `litellm_logging_obj`.
+ # base class declares non-Optional types for these
+ # fields but the bridge path (LiteLLMCompletionStreamingIterator)
+ # can legitimately omit them at runtime — keep the None
+ # fallback. Same lines passed mypy on the pre-fix file
+ # because the surrounding function body wasn't fully
+ # type-narrowed; the new typed terminal-event tuple above
+ # is what made these surface.
+ self.response = getattr(source_iterator, "response", None)
+ self.model = getattr(source_iterator, "model", None)
+ self.logging_obj = getattr(
+ source_iterator,
+ "logging_obj",
+ getattr(source_iterator, "litellm_logging_obj", None),
+ )
+ self.finished = False
+ self.responses_api_provider_config = getattr(source_iterator, "responses_api_provider_config", None)
+ self.completed_response = None
+ self.start_time = getattr(source_iterator, "start_time", datetime.now())
+ self._failure_handled = False
+ self._yielded_first_chunk = False
+ self._generated_content = ""
+ self._completed_response_cached = False
+ self._completed_response_logged = False
+ self._completed_response_cache_hit = None
+ self._persist_completed_response_before_logging = True
+ self._stream_created_time = time.time()
+ self.litellm_metadata = getattr(source_iterator, "litellm_metadata", None)
+ self.custom_llm_provider = getattr(source_iterator, "custom_llm_provider", None)
+ self.request_data = getattr(source_iterator, "request_data", {}) or {}
+ self.call_type = getattr(source_iterator, "call_type", None)
+ # Preserve hidden params so response headers (model_id,
+ # api_base, additional_headers) keep flowing.
+ self._hidden_params = dict(getattr(source_iterator, "_hidden_params", None) or {})
+
+ def adopt_fallback_headers(self, fallback_response: object) -> tuple[dict[str, object], dict[str, object]]:
+ prepared: Final = PythonCore._prepare_fallback_hidden_params(fallback_response)
+ self._billing_source = fallback_response
+ self._hidden_params = {**prepared[0], "additional_headers": prepared[1]}
+ self.fallback_headers_adopted = True
+ return prepared
+
+ def partial_response_on_disconnect(self) -> "ResponseIncompleteEvent | None":
+ """
+ The event to bill on a client disconnect. After a mid-stream hop the primary
+ stream's relayed tokens are folded into the fallback's event, the way
+ _combine_responses_fallback_usage does for a natural end of stream.
+ """
+ from litellm.responses.streaming_iterator import partial_responses_incomplete_event_on_disconnect
+
+ current: Final = partial_responses_incomplete_event_on_disconnect(self._billing_source)
+ if self._billing_source is source_iterator:
+ return current
+ primary: Final = partial_responses_incomplete_event_on_disconnect(source_iterator)
+ if current is None or primary is None:
+ return current or primary
+ if primary.response.usage is not None:
+ PythonCore._combine_responses_fallback_usage(current, primary.response.usage)
+ return current
+
+ def __aiter__(self):
+ return self
+
+ @property
+ def yielded_first_chunk(self) -> bool:
+ return self._yielded_first_chunk
+
+ async def __anext__(self):
+ try:
+ chunk: Final = await self._async_generator.__anext__()
+ except StopAsyncIteration:
+ # The inner generator is exhausted. If we never sniffed a
+ # terminal event off a chunk (the bridge path emits the
+ # final response.completed via common_done_event_logic,
+ # which raises StopAsyncIteration after returning it),
+ # fall back to whatever the source iterator latched so
+ # the proxy's container-ownership hook still sees a
+ # completed_response instead of logging a spurious
+ # "no completed_response" warning.
+ if self.completed_response is None:
+ self.completed_response = getattr(source_iterator, "completed_response", None)
+ raise
+ # Sniff the terminal stream event off each forwarded chunk
+ # so ``self.completed_response`` is populated regardless of
+ # which inner iterator produced it (source_iterator,
+ # fallback_iterator, or any future wrapper). Without this
+ # the proxy's container-ownership hook (which reads
+ # ``getattr(stream_response, "completed_response", None)``
+ # via extract_completed_responses_response) silently
+ # records nothing on streaming /v1/responses calls — every
+ # follow-up /v1/containers//files call then 403s for
+ # the very key that created the container (#30210).
+ if self.completed_response is None and getattr(chunk, "type", None) in _RESPONSES_TERMINAL_EVENT_TYPES:
+ self.completed_response = chunk
+ self._yielded_first_chunk = True
+ return chunk
+
+ async def aclose(self):
+ # async generators always expose aclose — no defensive check needed.
+ await self._async_generator.aclose()
+
+ async def stream_with_fallbacks():
+ held_lifecycle_events: tuple[object, ...] = () # rebind-ok: flushed at first output, dropped on fallback
+ response_id_exposed = False # rebind-ok: set once, before an early-released lifecycle event is yielded
+ try:
+ async for item in cast( # cast-ok: response iterators are async streams
+ AsyncIterator[object], source_iterator
+ ):
+ if not response_id_exposed and self._aresponses_background_stream_can_release(
+ item, source_iterator, initial_kwargs
+ ):
+ response_id_exposed = True
+ if not response_id_exposed and _responses_stream_holds_event(item, len(held_lifecycle_events)):
+ held_lifecycle_events = (*held_lifecycle_events, item)
+ continue
+ for held_event in held_lifecycle_events:
+ yield held_event
+ held_lifecycle_events = ()
+ yield item
+ for held_event in held_lifecycle_events:
+ yield held_event
+ except (MidStreamFallbackError, litellm_exceptions.ContextWindowExceededError) as stream_error:
+ e: Final = (
+ _context_window_stream_fallback_error(
+ stream_error,
+ source_iterator.model or "",
+ source_iterator.custom_llm_provider or "",
+ wrapper.yielded_first_chunk,
+ )
+ if isinstance(stream_error, litellm_exceptions.ContextWindowExceededError)
+ or isinstance(stream_error.original_exception, litellm_exceptions.ContextWindowExceededError)
+ else stream_error
+ )
+ if response_id_exposed:
+ self._raise_released_stream_error(e, initial_kwargs)
+ async with contextlib.aclosing(
+ self._aresponses_fallback_attempt(
+ e, source_iterator, initial_kwargs, wrapper.adopt_fallback_headers, held_lifecycle_events
+ )
+ ) as fallback_stream:
+ async for fallback_item in fallback_stream:
+ yield fallback_item
+ except Exception:
+ for held_event in held_lifecycle_events:
+ yield held_event
+ raise
+ finally:
+ with anyio.CancelScope(shield=True):
+ if hasattr(source_iterator, "aclose"):
+ try:
+ await source_iterator.aclose()
+ except Exception as exc:
+ verbose_router_logger.debug(
+ "stream_with_fallbacks(aresponses): error closing source: %s",
+ exc,
+ )
+
+ wrapper: Final = FallbackResponsesStreamWrapper(stream_with_fallbacks())
+ return wrapper
+
+ def _aresponses_background_stream_can_release(
+ self,
+ item: object,
+ source_iterator: "BaseResponsesAPIStreamingIterator",
+ kwargs: Mapping[str, object],
+ ) -> bool:
+ from litellm.responses.streaming_iterator import ResponsesAPIStreamingIterator
+
+ if (
+ type(source_iterator) is not ResponsesAPIStreamingIterator
+ or source_iterator.custom_llm_provider != "openai"
+ ):
+ return False
+ if not _responses_event_announces_background_run(item):
+ return False
+ model_group: Final = kwargs.get("model")
+ return isinstance(model_group, str) and not self._stream_can_fall_back(model_group, kwargs)
+
+ def _raise_released_stream_error(
+ self, e: "MidStreamFallbackError", initial_kwargs: Mapping[str, object]
+ ) -> NoReturn:
+ original: Final = e.original_exception
+ model_group: Final = initial_kwargs.get("model")
+ if (
+ self._host.dynamic().expose_router_debug_in_errors
+ and isinstance(original, litellm_exceptions.ContentPolicyViolationError)
+ and isinstance(model_group, str)
+ and not fallbacks_disabled_for_request(initial_kwargs)
+ and _mid_stream_error_before_output(e)
+ ):
+ hop_depth: Final = initial_kwargs.get("fallback_depth")
+ original.message += format_content_policy_no_fallback_note(
+ model_group,
+ fallback_lookup_groups(initial_kwargs, model_group),
+ _request_fallback_list(
+ initial_kwargs,
+ "content_policy_fallbacks",
+ cast( # cast-ok: untyped router attribute
+ "list[object] | None", self._core_content_policy_fallbacks
+ ),
+ ),
+ _request_fallback_list(
+ initial_kwargs,
+ "fallbacks",
+ cast("list[object] | None", self._core_fallbacks), # cast-ok: untyped router attribute
+ ),
+ isinstance(hop_depth, int) and hop_depth > 0,
+ )
+ PythonCore._raise_original_mid_stream_error(e)
+
+ def _responses_continuation(
+ self,
+ e: "MidStreamFallbackError",
+ source_iterator: "BaseResponsesAPIStreamingIterator",
+ request_kwargs: Mapping[str, object],
+ ) -> ResponsesContinuation | None:
+ """What a fallback continues from once a Responses stream broke after text, raising the primary's error
+ when the request constrains its output or the stream began a tool call, since a continued JSON document or
+ tool call would not parse, and when a content filter cut it, so no other model finishes filtered text."""
+ if _mid_stream_error_before_output(e):
+ return None
+ if source_iterator.started_tool_call or isinstance(
+ e.original_exception, litellm_exceptions.ContentPolicyViolationError
+ ):
+ PythonCore._raise_original_mid_stream_error(e)
+ cut_deployment_id: Final = (get_hidden_params(source_iterator) or {}).get("model_id")
+ cut_deployment: Final = (
+ self._core_get_deployment(model_id=cut_deployment_id) if isinstance(cut_deployment_id, str) else None
+ )
+ if constrains_responses_continuation(self._params_sent_to_deployment(request_kwargs, cut_deployment)):
+ PythonCore._raise_original_mid_stream_error(e)
+ earlier: Final = request_kwargs.get(RESPONSES_CONTINUATION_KWARG)
+ if isinstance(earlier, ResponsesContinuation):
+ return earlier.extended(e.generated_content)
+ return ResponsesContinuation(request_kwargs.get("input"), e.generated_content)
+
+ @staticmethod
+ def _responses_continuation_ceilings(
+ e: "MidStreamFallbackError", request_kwargs: Mapping[str, object]
+ ) -> Mapping[str, object]:
+ """The caller's output ceilings less what the cut stream already emitted, raising the primary's error once
+ nothing is left."""
+ emitted_tokens: Final = token_counter(model=e.model or "", text=e.generated_content)
+ reduced: Final = continuation_output_ceilings(request_kwargs, emitted_tokens)
+ if reduced is None:
+ PythonCore._raise_original_mid_stream_error(e)
+ return reduced
+
+ async def _aresponses_fallback_attempt(
+ self,
+ e: "MidStreamFallbackError",
+ source_iterator: "BaseResponsesAPIStreamingIterator",
+ initial_kwargs: dict[str, Any], # mutable-ok: mutated in-place before re-entering the fallback chain
+ adopt_headers: Callable[[object], tuple[dict[str, object], dict[str, object]]], # mutable-ok: hidden params
+ held_lifecycle_events: tuple[object, ...],
+ ) -> AsyncGenerator[object, None]:
+ """
+ Re-enters the Router's fallback chain for a mid-stream Responses API error and yields
+ whatever the fallback attempt produces. The lifecycle events the primary stream held
+ back reach the client only when no fallback lands, so the client sees exactly one
+ response announced, the one whose id completes. Split out of
+ _aresponses_streaming_iterator to keep each function's cyclomatic complexity within
+ the repo's C901 budget.
+ """
+ from litellm.exceptions import MidStreamFallbackError
+
+ continuation: Final = self._responses_continuation(e, source_iterator, initial_kwargs)
+ reduced_ceilings: Final[Mapping[str, object]] = (
+ PythonCore._responses_continuation_ceilings(e, initial_kwargs) if continuation is not None else {}
+ )
+ seam: Final = ResponsesContinuationSeam(continuation.emitted_text if continuation is not None else "")
+ partial_usage: Final = PythonCore._extract_partial_responses_usage(source_iterator)
+ fallback_response = None # rebind-ok: pre-init so finally can close it if a fallback was actually attempted
+ fallback_yielded = False # rebind-ok: flipped on the first fallback item so a fallback that dies before its first event still replays the primary's held announcement
+ try:
+ model_group: Final = cast(str, initial_kwargs.get("model")) # cast-ok: model group
+ fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the common_utils list|None param
+ "fallbacks", self._core_fallbacks
+ )
+ context_window_fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the param below
+ "context_window_fallbacks", self._core_context_window_fallbacks
+ )
+ content_policy_fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the param below
+ "content_policy_fallbacks", self._core_content_policy_fallbacks
+ )
+ initial_kwargs["original_function"] = ( # rebind-ok: the fallback chain re-enters on the same kwargs
+ self._ageneric_api_call_with_fallbacks_responses_attempt
+ )
+ if continuation is not None:
+ initial_kwargs.update(reduced_ceilings)
+ initial_kwargs[RESPONSES_CONTINUATION_KWARG] = continuation # rebind-ok: shaped per fallback deployment
+ initial_kwargs["input"] = responses_continuation_input(continuation) # rebind-ok: fallback hop input
+ # The Responses-API path stores observability metadata
+ # under "litellm_metadata" (not the default "metadata") —
+ # see _ageneric_api_call_with_fallbacks. Mirroring that
+ # here ensures model_group, model_group_alias, and trace
+ # ids land in the same key litellm.aresponses reads from.
+ self._update_kwargs_before_fallbacks(
+ model=model_group,
+ kwargs=initial_kwargs,
+ metadata_variable_name="litellm_metadata",
+ )
+ # The content-policy dispatch branch matches on the trigger's own type, so a refusal's
+ # MidStreamFallbackError envelope is unwrapped here or the wrong fallback list is consulted.
+ fallback_trigger: Final[Exception] = (
+ e.original_exception
+ if isinstance(
+ e.original_exception,
+ (litellm_exceptions.ContentPolicyViolationError, litellm_exceptions.ContextWindowExceededError),
+ )
+ else e
+ )
+ fallback_response = await self.async_function_with_fallbacks_common_utils( # rebind-ok: set on success
+ e=fallback_trigger,
+ disable_fallbacks=fallbacks_disabled_for_request(initial_kwargs),
+ fallbacks=fallbacks,
+ context_window_fallbacks=context_window_fallbacks,
+ content_policy_fallbacks=content_policy_fallbacks,
+ model_group=model_group,
+ args=(),
+ kwargs=initial_kwargs,
+ include_fallback_errors=initial_kwargs.get("include_fallback_errors", False) is True,
+ )
+ prepared_fallback_hidden_params: Final = adopt_headers(fallback_response)
+ if hasattr(fallback_response, "__aiter__"):
+ async for fallback_item in seam.splice(fallback_response):
+ PythonCore._apply_fallback_hidden_params_to_item(fallback_item, prepared_fallback_hidden_params)
+ if partial_usage is not None:
+ PythonCore._combine_responses_fallback_usage(fallback_item, partial_usage)
+ fallback_yielded = True
+ yield fallback_item
+ else:
+ fallback_yielded = True # rebind-ok: see the pre-init above
+ yield fallback_response
+ except Exception as fallback_error:
+ verbose_router_logger.error("Responses streaming fallback also failed: %s", fallback_error)
+ if not fallback_yielded:
+ for held_event in held_lifecycle_events:
+ yield held_event
+ if isinstance(fallback_error, MidStreamFallbackError) and fallback_error.original_exception is not None:
+ raise fallback_error.original_exception from fallback_error
+ raise
+ finally:
+ if fallback_response is not None and hasattr(fallback_response, "aclose"):
+ with anyio.CancelScope(shield=True):
+ try:
+ await fallback_response.aclose()
+ except Exception as exc:
+ verbose_router_logger.debug(
+ "stream_with_fallbacks(aresponses): error closing fallback: %s",
+ exc,
+ )
+
+ def _completion_streaming_iterator(
+ self,
+ model_response: CustomStreamWrapper,
+ messages: list[dict[str, str]],
+ initial_kwargs: dict,
+ first_chunk: _FirstChunk[Iterator[object]] | None = None,
+ ) -> CustomStreamWrapper:
+ """
+ Sync equivalent of _acompletion_streaming_iterator.
+
+ Wraps a sync streaming response so that MidStreamFallbackError
+ (raised by CustomStreamWrapper.__next__) triggers the Router's
+ fallback chain instead of surfacing directly to the caller.
+ """
+ from litellm.exceptions import MidStreamFallbackError
+
+ class SyncFallbackStreamWrapper(FallbackAwareStreamWrapper):
+ def __init__(self, sync_generator: Generator):
+ super().__init__(
+ completion_stream=sync_generator,
+ model=model_response.model,
+ custom_llm_provider=model_response.custom_llm_provider,
+ logging_obj=model_response.logging_obj,
+ _response_headers=getattr(model_response, "_response_headers", None),
+ )
+ self._sync_generator = sync_generator
+ model_response_hidden_params: Final = get_hidden_params(model_response)
+ if model_response_hidden_params is not None:
+ self._hidden_params = dict(model_response_hidden_params)
+
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ return next(self._sync_generator)
+
+ router_self: Final = self
+
+ def stream_with_fallbacks():
+ fallback_response = None
+ try:
+ for item in _chunks_with_first(model_response, first_chunk):
+ yield item
+ except (MidStreamFallbackError, litellm_exceptions.ContextWindowExceededError) as stream_error:
+ e: Final = (
+ _context_window_stream_fallback_error(
+ stream_error,
+ getattr(model_response, "model", ""),
+ model_response.custom_llm_provider or "",
+ bool(getattr(model_response, "response_uptil_now", ""))
+ or _stream_chunks_have_generated_content(model_response.chunks),
+ )
+ if isinstance(stream_error, litellm_exceptions.ContextWindowExceededError)
+ or isinstance(stream_error.original_exception, litellm_exceptions.ContextWindowExceededError)
+ else stream_error
+ )
+ if fallbacks_disabled_for_request(initial_kwargs) or (
+ not e.is_pre_first_chunk
+ and (e.generated_content or _stream_chunks_have_generated_content(model_response.chunks))
+ ):
+ if e.original_exception is not None:
+ raise e.original_exception from e
+ raise
+
+ try:
+ model_group: Final = cast(str, initial_kwargs.get("model"))
+ fallbacks: Final[list | None] = initial_kwargs.get("fallbacks", router_self._core_fallbacks)
+ context_window_fallbacks: Final[list | None] = initial_kwargs.get(
+ "context_window_fallbacks",
+ router_self._core_context_window_fallbacks,
+ )
+ content_policy_fallbacks: Final[list | None] = initial_kwargs.get(
+ "content_policy_fallbacks",
+ router_self._core_content_policy_fallbacks,
+ )
+ initial_kwargs["original_function"] = router_self._completion
+ initial_kwargs["messages"] = messages
+ router_self._update_kwargs_before_fallbacks(model=model_group, kwargs=initial_kwargs)
+ fallback_response = run_async_function(
+ router_self.async_function_with_fallbacks_common_utils,
+ e=e.original_exception
+ if isinstance(e.original_exception, litellm_exceptions.ContextWindowExceededError)
+ else e,
+ disable_fallbacks=fallbacks_disabled_for_request(initial_kwargs),
+ fallbacks=fallbacks,
+ context_window_fallbacks=context_window_fallbacks,
+ content_policy_fallbacks=content_policy_fallbacks,
+ model_group=model_group,
+ args=(),
+ kwargs=initial_kwargs,
+ include_fallback_errors=initial_kwargs.get("include_fallback_errors", False) is True,
+ )
+
+ if hasattr(fallback_response, "__iter__"):
+ prepared_fallback_hidden_params = PythonCore._adopt_fallback_response_headers(
+ wrapper_ref, fallback_response
+ )
+ fallback_headers_are_settled = False
+ for fallback_item in cast(Iterable[object], fallback_response): # cast-ok: __iter__ was checked
+ if not fallback_headers_are_settled:
+ fallback_headers_are_settled = True
+ # a fallback that failed over again only repoints itself once it yields
+ prepared_fallback_hidden_params = PythonCore._adopt_fallback_response_headers(
+ wrapper_ref, fallback_response
+ )
+ PythonCore._apply_fallback_hidden_params_to_item(
+ fallback_item, prepared_fallback_hidden_params
+ )
+ if (
+ fallback_item
+ and isinstance(fallback_item, ModelResponseStream)
+ and hasattr(fallback_item, "usage")
+ ):
+ router_self._combine_fallback_usage(fallback_item, None)
+ yield fallback_item
+ else:
+ yield None
+
+ except Exception as fallback_error:
+ verbose_router_logger.error("Fallback also failed: %s", fallback_error)
+ if (
+ isinstance(fallback_error, MidStreamFallbackError)
+ and fallback_error.original_exception is not None
+ ):
+ raise fallback_error.original_exception from fallback_error
+ raise fallback_error
+ finally:
+ if hasattr(model_response, "close"):
+ try:
+ model_response.close()
+ except BaseException as close_err:
+ verbose_router_logger.debug(
+ "stream_with_fallbacks: error closing model_response: %s",
+ close_err,
+ )
+ if fallback_response is not None and hasattr(fallback_response, "close"):
+ try:
+ fallback_response.close()
+ except BaseException as close_err:
+ verbose_router_logger.debug(
+ "stream_with_fallbacks: error closing fallback_response: %s",
+ close_err,
+ )
+
+ wrapped_response: Final = SyncFallbackStreamWrapper(stream_with_fallbacks())
+ # weak, for the same reason as the async twin
+ wrapper_ref: Final[weakref.ref[FallbackAwareStreamWrapper]] = weakref.ref(wrapped_response)
+ return wrapped_response
+
+ async def _silent_experiment_acompletion(self, silent_model: str, messages: Sequence[Mapping[str, str]], **kwargs):
+ """
+ Run a silent experiment in the background.
+ """
+ try:
+ # Prevent infinite recursion if silent model also has a silent model
+ if kwargs.get("metadata", {}).get("is_silent_experiment", False):
+ return
+
+ messages = copy.deepcopy(messages)
+
+ verbose_router_logger.info("Starting silent experiment for model %s", silent_model)
+
+ silent_kwargs: Final = self._get_silent_experiment_kwargs(**kwargs)
+ # Override model_group to correctly attribute metrics to the silent model
+ silent_kwargs["metadata"]["model_group"] = silent_model
+
+ # Trigger the silent request
+ await self._run_silent_experiment(silent_model, messages, silent_kwargs)
+ except Exception as e:
+ verbose_router_logger.error("Silent experiment failed for model %s: %s", silent_model, e)
+
+ async def _acompletion(
+ self, model: str, messages: list[dict[str, str]], **kwargs
+ ) -> ModelResponse | CustomStreamWrapper:
+ """
+ - Get an available deployment
+ - call it with a semaphore over the call
+ - semaphore specific to it's rpm
+ - in the semaphore, make a check against it's local rpm before running
+ """
+ model_name = None
+ deployment = None
+ _timeout_debug_deployment_dict = {} # this is a temporary dict to debug timeout issues
+ try:
+ controls: Final = kwargs.pop(MID_STREAM_FALLBACK_CONTROLS_KEY, None)
+ input_kwargs_for_streaming_fallback: Final = mid_stream_fallback_snapshot_kwargs(
+ model=model, controls=controls, kwargs=kwargs
+ )
+
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ start_time: Final = time.time()
+ deployment = await self._core_async_get_available_deployment(
+ model=model,
+ messages=messages,
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+ self._drop_unsupported_classifier_reasoning_effort(
+ deployment=cast(DeploymentTypedDict, deployment), # cast-ok: selection returns a router deployment
+ model=model,
+ kwargs=kwargs,
+ )
+
+ _timeout_debug_deployment_dict = deployment
+ end_time: Final = time.time()
+ _duration: Final = end_time - start_time
+ asyncio.create_task(
+ self.service_logger_obj.async_service_success_hook(
+ service=ServiceTypes.ROUTER,
+ duration=_duration,
+ call_type="async_get_available_deployment",
+ start_time=start_time,
+ end_time=end_time,
+ parent_otel_span=get_parent_otel_span_from_kwargs(kwargs),
+ )
+ )
+
+ # debug how often this deployment picked
+
+ self._track_deployment_metrics(deployment=deployment, parent_otel_span=parent_otel_span)
+
+ # Check for silent model experiment
+ # Make a local copy of litellm_params to avoid mutating the Router's state
+ litellm_params: Final = self._deployment_params_with_request_reasoning_override(
+ deployment["litellm_params"], kwargs
+ )
+ silent_model: Final = litellm_params.pop("silent_model", None)
+
+ for silent_target in _silent_experiment_targets(silent_model):
+ # Mirroring traffic to a secondary model
+ # This is a silent experiment, so we don't want to block the primary request
+ asyncio.create_task(
+ self._silent_experiment_acompletion(
+ silent_model=silent_target,
+ messages=messages, # Use messages instead of *args
+ **_silent_experiment_kwargs_snapshot(kwargs),
+ )
+ )
+
+ kwargs.setdefault("messages", messages)
+ self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, splits_stream_timeout=True)
+ kwargs.pop("silent_model", None) # Ensure it's not in kwargs either
+
+ model_name = litellm_params["model"]
+
+ model_client: Final = self._get_async_openai_model_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ )
+ self.total_calls[model_name] += 1
+
+ input_kwargs: Final = without_router_only_kwargs(
+ {
+ **litellm_params,
+ "messages": messages,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+
+ logging_obj: Final[LiteLLMLogging | None] = kwargs.get("litellm_logging_obj", None)
+
+ max_parallel_requests_limit: Final = self._get_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ client_type="max_parallel_requests",
+ )
+ compacted_input: Final = await compact_to_fit(self._core_ctx, deployment, input_kwargs, "chat")
+ async with contextlib.AsyncExitStack() as deployment_slot:
+ if isinstance(max_parallel_requests_limit, MaxParallelRequestsLimit):
+ deployment_slot.enter_context(max_parallel_requests_limit)
+ await self.async_routing_strategy_pre_call_checks(
+ deployment=deployment,
+ logging_obj=logging_obj,
+ parent_otel_span=parent_otel_span,
+ )
+ response = await self._host.invoke("acompletion")(**compacted_input)
+
+ ## CHECK CONTENT FILTER ERROR ##
+ if isinstance(response, ModelResponse):
+ _should_raise = self._should_raise_content_policy_error(
+ model=model, response=response, kwargs=kwargs
+ )
+ if _should_raise:
+ raise litellm_exceptions.ContentPolicyViolationError(
+ message="Response output was blocked.",
+ model=model,
+ llm_provider="",
+ )
+
+ if (
+ isinstance(response, CustomStreamWrapper)
+ and response.completion_stream is None
+ and response.make_call is not None
+ ):
+ await response.fetch_stream()
+ first_chunk: Final = (
+ await _aread_first_chunk(response) if isinstance(response, CustomStreamWrapper) else None
+ )
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.acompletion(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+ # debug how often this deployment picked
+ self._track_deployment_metrics(
+ deployment=deployment,
+ response=response,
+ parent_otel_span=parent_otel_span,
+ )
+
+ if isinstance(response, CustomStreamWrapper):
+ return await self._acompletion_streaming_iterator(
+ model_response=response,
+ messages=messages,
+ initial_kwargs=input_kwargs_for_streaming_fallback,
+ deployment_slot=deployment_slot.pop_all(),
+ first_chunk=first_chunk,
+ )
+
+ return response
+ except litellm_exceptions.Timeout as e:
+ deployment_litellm_params: Final[Mapping[str, object]] = _timeout_debug_deployment_dict.get(
+ "litellm_params", {}
+ )
+ if self._host.dynamic().expose_router_debug_in_errors:
+ e.message += (
+ f"\n\nDeployment Info: request_timeout: {deployment_litellm_params.get('request_timeout')}"
+ f"\ntimeout: {deployment_litellm_params.get('timeout')}"
+ f"\nstream_timeout: {deployment_litellm_params.get('stream_timeout')}"
+ )
+ # Set per-deployment num_retries on exception for retry logic
+ if deployment is not None:
+ self._set_deployment_num_retries_on_exception(e, deployment)
+ self._stamp_failed_deployment_id_with_effective_model_info(e, deployment, kwargs)
+ raise e
+ except Exception as e:
+ verbose_router_logger.info("litellm.acompletion(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
+ if model_name is not None:
+ self.fail_calls[model_name] += 1
+ # Set per-deployment num_retries on exception for retry logic
+ if deployment is not None:
+ self._set_deployment_num_retries_on_exception(e, deployment)
+ self._stamp_failed_deployment_id_with_effective_model_info(e, deployment, kwargs)
+ raise e
+
+ def _update_kwargs_before_fallbacks(
+ self,
+ model: str,
+ kwargs: dict,
+ metadata_variable_name: str | None = "metadata",
+ ) -> None:
+ """
+ Adds/updates to kwargs:
+ - litellm_trace_id
+ - metadata
+
+ num_retries is deliberately left as the caller passed it, with no default filled in.
+ async_function_with_retries resolves the router/global default itself, and it can only
+ honour the precedence request > deployment litellm_params > litellm_settings while an
+ absent num_retries still means "the caller did not ask for one".
+ """
+ kwargs.setdefault("litellm_trace_id", str(uuid.uuid4()))
+ model_group_alias: str | None = None
+ if self._core_get_model_from_alias(model=model):
+ model_group_alias = model
+ kwargs.setdefault(metadata_variable_name, {}).update(
+ {"model_group": model, "model_group_alias": model_group_alias}
+ )
+
+ def _set_deployment_num_retries_on_exception(self, exception: Exception, deployment: dict) -> None:
+ """
+ Set num_retries from deployment litellm_params on the exception.
+
+ This allows the retry logic in async_function_with_retries to use
+ per-deployment retry settings instead of the global setting.
+ """
+ # Only set if exception doesn't already have num_retries
+ if hasattr(exception, "num_retries") and exception.num_retries is not None:
+ return
+
+ litellm_params: Final = deployment.get("litellm_params", {})
+ dep_num_retries: Final = litellm_params.get("num_retries")
+ if dep_num_retries is not None:
+ try:
+ exception.num_retries = int(dep_num_retries) # Handle both int and str
+ except (ValueError, TypeError):
+ pass # Skip if value can't be converted to int
+
+ def _set_failed_deployment_id_on_exception(self, exception: Exception, deployment: Mapping[str, Any]) -> None:
+ """
+ Stamp the failed deployment's `model_info.id` on the exception so the
+ fallback layer can exclude it from subsequent re-picks within the same
+ request (used by weighted-routing failover).
+
+ Idempotent: never overwrites an existing value, so the id of the
+ deployment that *first* failed in a chain is preserved if multiple
+ layers re-raise.
+ """
+ if getattr(exception, "failed_deployment_id", None):
+ return
+ deployment_id: Final = (deployment.get("model_info") or {}).get("id")
+ if deployment_id:
+ try:
+ exception.failed_deployment_id = deployment_id
+ except Exception:
+ pass
+
+ def _stamp_failed_deployment_id_with_effective_model_info(
+ self, exception: Exception, deployment: Mapping[str, object], kwargs: Mapping[str, object]
+ ) -> None:
+ # A client-side-credential call gets a dynamic deployment id generated inside
+ # _update_kwargs_with_deployment and stamped into kwargs["model_info"]; stamping
+ # the static shared deployment's id instead would let one tenant's bad credentials
+ # cool down the deployment every other tenant sharing this config relies on.
+ effective_model_info: Final = kwargs.get("model_info") or deployment.get("model_info") or MappingProxyType({})
+ self._set_failed_deployment_id_on_exception(exception, MappingProxyType({"model_info": effective_model_info}))
+
+ @staticmethod
+ def _stamp_retry_skip_deployment_id(exception: Exception, kwargs: Mapping[str, object]) -> None:
+ effective_model_info: Final = kwargs.get("model_info")
+ if not isinstance(effective_model_info, Mapping):
+ return
+ model_info: Final = _MODEL_INFO_ADAPTER.validate_python(effective_model_info)
+ configured_deployment_id: Final = (
+ model_info.get("original_model_id")
+ if isinstance(exception, litellm_exceptions.UnsupportedEndpointError)
+ else None
+ )
+ deployment_id: Final = configured_deployment_id or model_info.get("id")
+ if isinstance(deployment_id, str) and deployment_id:
+ exception.retry_skip_deployment_id = deployment_id # pyright: ignore[reportAttributeAccessIssue] # dynamic stamp, read by _deployment_ids_to_skip_on_retry
+
+ def _update_kwargs_with_default_litellm_params(
+ self, kwargs: dict, metadata_variable_name: str | None = "metadata"
+ ) -> None:
+ """
+ Adds default litellm params to kwargs, if set.
+
+ Handles inserting this as either "metadata" or "litellm_metadata" depending on the metadata_variable_name
+ """
+ # 1) copy your defaults and pull out metadata
+ defaults: Final = dict(without_retrieve_stream_defaults(self._core_default_litellm_params, kwargs))
+ metadata_defaults: Final = defaults.pop("metadata", {}) or {}
+
+ # 2) add any non-metadata defaults that aren't already in kwargs
+ for key, value in defaults.items():
+ if value is None:
+ continue
+ kwargs.setdefault(key, value)
+
+ # 3) merge in metadata, this handles inserting this as either "metadata" or "litellm_metadata"
+ kwargs.setdefault(metadata_variable_name, {}).update(metadata_defaults)
+
+ def _handle_clientside_credential(
+ self, deployment: dict, kwargs: dict, function_name: str | None = None
+ ) -> Deployment:
+ """
+ Build a per-request Deployment carrying the caller-supplied api_key/api_base,
+ with its own stable id for cooldown, logging, and cost-map identity.
+
+ This deployment is deliberately never registered with the router (no
+ upsert_deployment/add_deployment call): doing so used to add it to
+ self._core_model_list under the shared model_name, which made a request-scoped,
+ caller-supplied provider credential a permanent, load-balanced deployment
+ that every other caller of that model group could be routed onto. Its
+ pricing is still registered directly, so a custom price configured on the
+ underlying deployment still applies to this call.
+ """
+ model_info: Final = deployment.get("model_info", {}).copy()
+ litellm_params: Final = deployment["litellm_params"].copy()
+ dynamic_litellm_params: Final = get_dynamic_litellm_params(litellm_params=litellm_params, request_kwargs=kwargs)
+ # Use deployment model_name as model_group for generating model_id
+ metadata_variable_name: Final = get_router_metadata_variable_name(
+ function_name=function_name,
+ )
+ model_group: Final = kwargs.get(metadata_variable_name, {}).get("model_group")
+ _model_id: Final = self._core_generate_model_id(model_group=model_group, litellm_params=dynamic_litellm_params)
+ original_model_id: Final = model_info.get("id")
+ model_info["id"] = _model_id
+ model_info["original_model_id"] = original_model_id
+ deployment_pydantic_obj: Final = Deployment(
+ model_name=model_group,
+ litellm_params=LiteLLM_Params.model_validate(dynamic_litellm_params),
+ model_info=model_info,
+ )
+ PythonCore._register_deployment_pricing(deployment=deployment_pydantic_obj)
+ return deployment_pydantic_obj
+
+ @staticmethod
+ def _merge_tools_from_deployment(
+ deployment: dict, kwargs: dict, defaults: Mapping[str, object] = _NO_DEFAULT_PARAMS
+ ) -> None:
+ """
+ Merge tools from deployment litellm_params with request kwargs.
+ When both have tools, concatenate them (deployment tools first, then request tools).
+ tool_choice: use request value if provided, else deployment's.
+ """
+ dep_params_raw: Final = deployment.get("litellm_params", {}) or {}
+ if isinstance(dep_params_raw, dict):
+ dep_params = dep_params_raw
+ else:
+ dep_params = dep_params_raw.model_dump(exclude_none=True)
+ if _drops_malformed_request_tools(dep_params, defaults, kwargs):
+ for name in TOOL_FIELDS:
+ kwargs.pop(name, None)
+ merged_tools: Final = _merged_tools(dep_params.get("tools") or [], kwargs.get("tools") or [])
+ if merged_tools is not None:
+ kwargs["tools"] = merged_tools
+ if "tool_choice" not in kwargs and dep_params.get("tool_choice") is not None:
+ kwargs["tool_choice"] = dep_params["tool_choice"]
+
+ def _update_kwargs_with_deployment(
+ self,
+ deployment: dict,
+ kwargs: dict,
+ function_name: str | None = None,
+ splits_stream_timeout: bool = False,
+ opens_responses_socket: bool = False,
+ ) -> None:
+ """
+ 5 jobs:
+ - Adds selected deployment, model_info and api_base to kwargs["metadata"] (used for logging)
+ - Adds default litellm params to kwargs, if set.
+ - Merges tools from deployment with request (proxy-configured tools + request tools).
+ - On a fallback hop, drops the encrypted reasoning this deployment cannot decrypt, keeping its summary.
+ - On a Responses continuation, sends the partial response as prefill where this deployment takes one.
+ """
+ for key in self._forwarded_alias_marker_keys_the_deployment_sets(
+ deployment=deployment, forwarded_keys=kwargs.pop(_ALIAS_MARKER_FORWARDED_PARAMS_KWARG, ())
+ ):
+ kwargs.pop(key, None)
+ kwargs.pop("daily_reset_time", None) # pyright: ignore[reportUnknownMemberType] # kwargs is an untyped dict
+ defaults: Final = _LITELLM_PARAMS_ADAPTER.validate_python(self._core_default_litellm_params)
+ self._merge_tools_from_deployment(deployment=deployment, kwargs=kwargs, defaults=defaults)
+
+ model_info = deployment.get("model_info", {}).copy()
+ deployment_litellm_model_name = deployment["litellm_params"]["model"]
+ deployment_api_base = deployment["litellm_params"].get("api_base")
+ deployment_model_name: Final = deployment["model_name"]
+ from litellm.litellm_core_utils.get_provider_specific_headers import ProviderSpecificHeaderUtils
+
+ request_context: Final = cast( # cast-ok: the router's legacy request dict has string keys
+ Mapping[str, object], kwargs
+ )
+ raw_deployment_params: Final[object] = deployment["litellm_params"]
+ deployment_context: Final = _LITELLM_PARAMS_ADAPTER.validate_python(
+ raw_deployment_params.model_dump(exclude_none=True)
+ if isinstance(raw_deployment_params, LiteLLM_Params)
+ else raw_deployment_params
+ )
+ _drop_malformed_request_params_the_deployment_sets(
+ kwargs, # pyright: ignore[reportUnknownArgumentType] # kwargs is the router's untyped request dict
+ deployment_context,
+ defaults,
+ )
+ declared_provider: Final = deployment_context.get("custom_llm_provider")
+ declared_model: Final = deployment_context.get("model")
+ deployment_provider: Final = (
+ declared_provider
+ if isinstance(declared_provider, str)
+ else inferred_provider(declared_model if isinstance(declared_model, str) else None)
+ )
+ forwarded_authorization: Final = (
+ ProviderSpecificHeaderUtils.forwarded_authorization(
+ request_context.get("provider_specific_header"), deployment_provider
+ )
+ if deployment_provider in _FORWARDED_CREDENTIAL_ISOLATION_PROVIDERS
+ else None
+ )
+ if is_clientside_credential(request_kwargs=kwargs) or forwarded_authorization is not None:
+ deployment_pydantic_obj: Final = self._handle_clientside_credential(
+ deployment=deployment,
+ kwargs={
+ **kwargs,
+ **({"api_key": forwarded_authorization} if forwarded_authorization is not None else {}),
+ },
+ function_name=function_name,
+ )
+ model_info = deployment_pydantic_obj.model_info.model_dump()
+ deployment_litellm_model_name = deployment_pydantic_obj.litellm_params.model
+ deployment_api_base = deployment_pydantic_obj.litellm_params.api_base
+
+ metadata_variable_name: Final = get_router_metadata_variable_name(
+ function_name=function_name,
+ )
+ deployment_that_just_failed: Final = _deployment_that_just_failed(
+ _FALLBACK_HOP_ADAPTER.validate_python(kwargs).get(metadata_variable_name)
+ )
+ routing: Final = deployment.get(KUBERNETES_POD_ROUTING_KEY)
+
+ kwargs.setdefault(metadata_variable_name, {}).update(
+ {
+ "deployment": deployment_litellm_model_name,
+ "model_info": model_info,
+ "api_base": deployment_api_base,
+ "deployment_model_name": deployment_model_name,
+ KUBERNETES_POD_ROUTING_KEY: routing,
+ }
+ )
+ other_bucket: Final = "metadata" if metadata_variable_name == "litellm_metadata" else "litellm_metadata"
+ other_bucket_metadata: Final = kwargs.get(other_bucket)
+ if isinstance(other_bucket_metadata, Mapping) and KUBERNETES_POD_ROUTING_KEY in other_bucket_metadata:
+ kwargs[other_bucket] = { # rebind-ok: this method updates the router's request kwargs in place
+ **other_bucket_metadata,
+ KUBERNETES_POD_ROUTING_KEY: routing,
+ }
+
+ # A retry/fallback reuses this same kwargs dict for the next deployment.
+ # Refund and clear any reservation the previous deployment attempt left
+ # here before it's wiped below, instead of relying on that attempt's
+ # (possibly still-pending) failure event to do it.
+ refund_stale_reservation_before_retry(self._core_cache, kwargs)
+ set_io_token_rate_limit_request_kwargs(
+ kwargs,
+ store_in_context=deployment_has_io_token_limits(deployment),
+ opens_responses_socket=opens_responses_socket,
+ metadata_channel=metadata_variable_name,
+ )
+
+ kwargs[metadata_variable_name].setdefault(
+ ROUTING_REQUEST_TAGS_METADATA_KEY,
+ tuple(get_tags_from_request_kwargs(kwargs, metadata_variable_name=metadata_variable_name)),
+ )
+
+ ## DEPLOYMENT-LEVEL TAGS
+ deployment_tags: Final = deployment.get("litellm_params", {}).get("tags")
+ merged_tags: Final = tags_with_serving_deployment_tags(kwargs[metadata_variable_name], deployment_tags)
+ if merged_tags is not None:
+ kwargs[metadata_variable_name]["tags"] = list(merged_tags)
+
+ baggage_stamp: Final = baggage_stamp_for_serving_deployment(kwargs[metadata_variable_name], deployment_tags)
+ if baggage_stamp is not None:
+ kwargs[metadata_variable_name][BAGGAGE_MAPPED_TAGS_METADATA_KEY] = baggage_stamp
+
+ ## CREDENTIAL NAME AS TAG
+ credential_name: Final = deployment.get("litellm_params", {}).get("litellm_credential_name")
+ if credential_name:
+ credential_tag: Final = f"Credential: {credential_name}"
+ existing_tags = kwargs[metadata_variable_name].get("tags") or []
+ if credential_tag not in existing_tags:
+ existing_tags.append(credential_tag)
+ kwargs[metadata_variable_name]["tags"] = existing_tags
+
+ from litellm.llms.github_copilot.per_user_auth import (
+ github_copilot_per_user_credential_name,
+ )
+
+ configured_per_user_name: Final = github_copilot_per_user_credential_name(
+ cast( # cast-ok: deployment litellm_params is a str-keyed dict
+ "Mapping[str, object]", deployment.get("litellm_params")
+ )
+ )
+ caller_credential_name: Final[object] = cast( # cast-ok: kwargs is the untyped request dict
+ "object", kwargs.get("litellm_credential_name")
+ )
+ if (
+ configured_per_user_name is not None
+ and isinstance(caller_credential_name, str)
+ and caller_credential_name != configured_per_user_name
+ ):
+ raise litellm_exceptions.BadRequestError(
+ message="litellm_credential_name cannot be overridden on a deployment that uses per-user GitHub OAuth",
+ model=deployment_model_name,
+ llm_provider="",
+ )
+
+ kwargs["model_info"] = model_info
+
+ if function_name == "_ageneric_api_call_with_fallbacks":
+ from litellm.passthrough.timeout_utils import (
+ resolve_llm_passthrough_timeout,
+ )
+
+ _router_timeout: Final = (
+ self.request_timeout
+ if self.request_timeout is not None
+ else float(self._explicit_timeout)
+ if isinstance(self._explicit_timeout, (int, float))
+ else None
+ )
+ _router_stream_timeout: Final = (
+ self.stream_timeout
+ if self.stream_timeout is not None
+ else self.request_timeout
+ if self.request_timeout is not None
+ else self._core_default_litellm_params.get("stream_timeout")
+ )
+ passthrough_timeout: Final = partial(
+ resolve_llm_passthrough_timeout,
+ litellm_params=deployment["litellm_params"],
+ router_timeout=_router_timeout,
+ router_stream_timeout=_router_stream_timeout,
+ )
+ timeout_request: Final = {**kwargs, "stream": True} if requests_streamed_retrieval(kwargs) else kwargs
+ kwargs.update(
+ CompletionTimeout.stream_request_kwargs(
+ passthrough_timeout(kwargs={**kwargs, "stream": False}), passthrough_timeout(kwargs=timeout_request)
+ )
+ if splits_stream_timeout and timeout_request.get("stream")
+ else {"timeout": passthrough_timeout(kwargs=timeout_request)}
+ )
+ elif splits_stream_timeout:
+ request_kwargs: Final = _REQUEST_KWARGS_ADAPTER.validate_python(kwargs)
+ deployment_params: Final = _DEPLOYMENT_PARAMS_ADAPTER.validate_python(deployment["litellm_params"])
+ kwargs.update(
+ CompletionTimeout.stream_request_kwargs(
+ self._get_non_stream_timeout(kwargs=request_kwargs, data=deployment_params),
+ self._get_stream_timeout(kwargs=request_kwargs, data=deployment_params)
+ if request_kwargs.get("stream")
+ else None,
+ )
+ )
+ else:
+ kwargs["timeout"] = self._get_timeout(
+ kwargs=_REQUEST_KWARGS_ADAPTER.validate_python(kwargs),
+ data=_DEPLOYMENT_PARAMS_ADAPTER.validate_python(deployment["litellm_params"]),
+ )
+
+ deployment_litellm_params: Final = _DEPLOYMENT_PARAMS_ADAPTER.validate_python(deployment["litellm_params"])
+ if router_config_requests_stream_usage(
+ _REQUEST_KWARGS_ADAPTER.validate_python(kwargs),
+ (
+ _LITELLM_PARAMS_ADAPTER.validate_python(
+ deployment_litellm_params
+ if isinstance(deployment_litellm_params, Mapping)
+ else deployment_litellm_params.model_dump(exclude_none=True)
+ ),
+ defaults,
+ ),
+ ):
+ kwargs[STREAM_USAGE_INJECTED_FIELD] = True
+ self._update_kwargs_with_default_litellm_params(kwargs=kwargs, metadata_variable_name=metadata_variable_name)
+ pinned_model_id: Final = deployment_model_id_pin(deployment_context)
+ if pinned_model_id is not None:
+ kwargs["model_id"] = pinned_model_id # rebind-ok: this method's contract is filling the caller's kwargs
+ hop_kwargs: Final = _FALLBACK_HOP_ADAPTER.validate_python(kwargs)
+ if _is_fallback_hop(hop_kwargs):
+ EncryptedContentAffinityCheck.strip_reasoning_the_targets_cannot_decrypt(
+ self._core_ctx,
+ hop_kwargs.get("input"),
+ hop_kwargs.get("messages"),
+ (_FALLBACK_HOP_ADAPTER.validate_python(deployment),),
+ unmarked_origin=deployment_that_just_failed,
+ )
+ if RESPONSES_CONTINUATION_KWARG in hop_kwargs:
+ del kwargs[RESPONSES_CONTINUATION_KWARG] # rebind-ok: this method prepares the call's kwargs in place
+ kwargs["input"] = responses_continuation_input_for( # rebind-ok: the deployment's continuation input
+ hop_kwargs, _FALLBACK_HOP_ADAPTER.validate_python(deployment)
+ )
+
+ def _get_async_openai_model_client(self, deployment: dict, kwargs: dict):
+ """
+ Helper to get AsyncOpenAI or AsyncAzureOpenAI client that was created for the deployment
+
+ The same OpenAI client is re-used to optimize latency / performance in production
+
+ If dynamic api key is provided:
+ Do not re-use the client. Pass model_client=None. The OpenAI/ AzureOpenAI client will be recreated in the handler for the llm provider
+ """
+ potential_model_client: Final = self._get_client(deployment=deployment, kwargs=kwargs, client_type="async")
+
+ # check if provided keys == client keys #
+ dynamic_api_key: Final = kwargs.get("api_key", None)
+ if (
+ dynamic_api_key is not None
+ and potential_model_client is not None
+ and dynamic_api_key != potential_model_client.api_key
+ ):
+ model_client = None
+ else:
+ model_client = potential_model_client
+
+ return model_client
+
+ def _get_stream_timeout(
+ self, kwargs: Mapping[str, object], data: Mapping[str, object] | GenericLiteLLMParams
+ ) -> object:
+ """Helper to get stream timeout from kwargs or deployment params"""
+ return (
+ kwargs.get("stream_timeout", None) # the params dynamically set by user
+ or get_service_tier_timeout(kwargs.get("service_tier"), data, "stream_timeout_by_service_tier")
+ or data.get("stream_timeout", None) # timeout set on litellm_params for this deployment
+ or self.stream_timeout # timeout set on router
+ or self.request_timeout # litellm_settings.request_timeout (per-attempt)
+ or self._core_default_litellm_params.get("stream_timeout", None)
+ )
+
+ def _get_non_stream_timeout(
+ self, kwargs: Mapping[str, object], data: Mapping[str, object] | GenericLiteLLMParams
+ ) -> object:
+ """Helper to get non-stream timeout from kwargs or deployment params"""
+ return (
+ kwargs.get("timeout", None) # the params dynamically set by user
+ or kwargs.get("request_timeout", None) # the params dynamically set by user
+ or get_service_tier_timeout(kwargs.get("service_tier"), data, "timeout_by_service_tier")
+ or data.get("timeout", None) # timeout set on litellm_params for this deployment
+ or data.get("request_timeout", None) # timeout set on litellm_params for this deployment
+ or self.request_timeout # litellm_settings.request_timeout (per-attempt)
+ or self.timeout # timeout set on router (router_settings.timeout)
+ or self._core_default_litellm_params.get("timeout", None)
+ )
+
+ def _get_timeout(self, kwargs: Mapping[str, object], data: Mapping[str, object] | GenericLiteLLMParams) -> object:
+ """Helper to get timeout from kwargs or deployment params"""
+ stream_timeout: Final = self._get_stream_timeout(kwargs=kwargs, data=data) if kwargs.get("stream") else None
+ return stream_timeout if stream_timeout is not None else self._get_non_stream_timeout(kwargs=kwargs, data=data)
+
+ async def abatch_completion(
+ self,
+ models: list[str],
+ messages: list[dict[str, str]] | list[list[dict[str, str]]],
+ **kwargs,
+ ):
+ """
+ Async Batch Completion. Used for 2 scenarios:
+ 1. Batch Process 1 request to N models on litellm.Router. Pass messages as List[Dict[str, str]] to use this
+ 2. Batch Process N requests to M models on litellm.Router. Pass messages as List[List[Dict[str, str]]] to use this
+
+ Example Request for 1 request to N models:
+ ```
+ response = await router.abatch_completion(
+ models=["gpt-3.5-turbo", "groq-llama"],
+ messages=[
+ {"role": "user", "content": "is litellm becoming a better product ?"}
+ ],
+ max_tokens=15,
+ )
+ ```
+
+
+ Example Request for N requests to M models:
+ ```
+ response = await router.abatch_completion(
+ models=["gpt-3.5-turbo", "groq-llama"],
+ messages=[
+ [{"role": "user", "content": "is litellm becoming a better product ?"}],
+ [{"role": "user", "content": "who is this"}],
+ ],
+ )
+ ```
+ """
+ ############## Helpers for async completion ##################
+
+ async def _async_completion_no_exceptions(model: str, messages: list[AllMessageValues], **kwargs):
+ """
+ Wrapper around self.async_completion that catches exceptions and returns them as a result
+ """
+ try:
+ return await self.acompletion(model=model, messages=messages, **kwargs)
+ except Exception as e:
+ return e
+
+ async def _async_completion_no_exceptions_return_idx(
+ model: str,
+ messages: list[AllMessageValues],
+ idx: int, # index of message this response corresponds to
+ **kwargs,
+ ):
+ """
+ Wrapper around self.async_completion that catches exceptions and returns them as a result
+ """
+ try:
+ return (
+ await self.acompletion(model=model, messages=messages, **kwargs),
+ idx,
+ )
+ except Exception as e:
+ return e, idx
+
+ ############## Helpers for async completion ##################
+
+ if isinstance(messages, list) and all(isinstance(m, dict) for m in messages):
+ _tasks = []
+ for model in models:
+ # add each task but if the task fails
+ _tasks.append(_async_completion_no_exceptions(model=model, messages=messages, **kwargs))
+ response = await asyncio.gather(*_tasks)
+ return response
+ elif isinstance(messages, list) and all(isinstance(m, list) for m in messages):
+ _tasks = []
+ for idx, message in enumerate(messages):
+ for model in models:
+ # Request Number X, Model Number Y
+ _tasks.append(
+ _async_completion_no_exceptions_return_idx(
+ model=model,
+ idx=idx,
+ messages=message,
+ **kwargs,
+ )
+ )
+ responses: Final = await asyncio.gather(*_tasks)
+ final_responses: Final[list[list[object]]] = [[] for _ in range(len(messages))]
+ for response in responses:
+ if isinstance(response, tuple):
+ final_responses[response[1]].append(response[0])
+ else:
+ final_responses[0].append(response)
+ return final_responses
+
+ async def abatch_completion_one_model_multiple_requests(
+ self, model: str, messages: list[list[AllMessageValues]], **kwargs
+ ):
+ """
+ Async Batch Completion - Batch Process multiple Messages to one model_group on litellm.Router
+
+ Use this for sending multiple requests to 1 model
+
+ Args:
+ model (List[str]): model group
+ messages (List[List[Dict[str, str]]]): list of messages. Each element in the list is one request
+ **kwargs: additional kwargs
+ Usage:
+ response = await self.abatch_completion_one_model_multiple_requests(
+ model="gpt-3.5-turbo",
+ messages=[
+ [{"role": "user", "content": "hello"}, {"role": "user", "content": "tell me something funny"}],
+ [{"role": "user", "content": "hello good mornign"}],
+ ]
+ )
+ """
+
+ async def _async_completion_no_exceptions(model: str, messages: list[AllMessageValues], **kwargs):
+ """
+ Wrapper around self.async_completion that catches exceptions and returns them as a result
+ """
+ try:
+ return await self.acompletion(model=model, messages=messages, **kwargs)
+ except Exception as e:
+ return e
+
+ _tasks: Final = []
+ for message_request in messages:
+ # add each task but if the task fails
+ _tasks.append(_async_completion_no_exceptions(model=model, messages=message_request, **kwargs))
+
+ response: Final = await asyncio.gather(*_tasks)
+ return response
+
+ # fmt: off
+
+ @overload
+ async def abatch_completion_fastest_response(
+ self, model: str, messages: list[dict[str, str]], stream: Literal[True], **kwargs
+ ) -> CustomStreamWrapper:
+ ...
+
+
+
+ @overload
+ async def abatch_completion_fastest_response(
+ self, model: str, messages: list[dict[str, str]], stream: Literal[False] = False, **kwargs
+ ) -> ModelResponse:
+ ...
+
+ # fmt: on
+
+ async def abatch_completion_fastest_response(
+ self,
+ model: str,
+ messages: list[dict[str, str]],
+ stream: bool = False,
+ **kwargs,
+ ):
+ """
+ model - List of comma-separated model names. E.g. model="gpt-4, gpt-3.5-turbo"
+
+ Returns fastest response from list of model names. OpenAI-compatible endpoint.
+ """
+ models: Final = [m.strip() for m in model.split(",")]
+
+ async def _async_completion_no_exceptions(
+ model_name: str, messages: list[dict[str, str]], stream: bool, **kwargs: object
+ ) -> ModelResponse | CustomStreamWrapper | Exception:
+ """
+ Wrapper around self.acompletion that catches exceptions and returns them as a result
+ """
+ try:
+ result = await self.acompletion(model=model_name, messages=messages, stream=stream, **kwargs)
+ return result
+ except asyncio.CancelledError:
+ verbose_router_logger.debug("Received 'task.cancel'. Cancelling call w/ model=%s.", model_name)
+ raise
+ except Exception as e:
+ return e
+
+ pending_tasks = []
+
+ async def check_response(task: asyncio.Task):
+ nonlocal pending_tasks
+ try:
+ result: Final = await task
+ if isinstance(result, (ModelResponse, CustomStreamWrapper)):
+ verbose_router_logger.debug("Received successful response. Cancelling other LLM API calls.")
+ # If a desired response is received, cancel all other pending tasks
+ for t in pending_tasks:
+ t.cancel()
+ return result
+ except Exception:
+ # Ignore exceptions, let the loop handle them
+ pass
+ finally:
+ # Remove the task from pending tasks if it finishes
+ try:
+ pending_tasks.remove(task)
+ except KeyError:
+ pass
+
+ for model_name in models:
+ task = asyncio.create_task(
+ _async_completion_no_exceptions(model_name=model_name, messages=messages, stream=stream, **kwargs)
+ )
+ pending_tasks.append(task)
+
+ # Await the first task to complete successfully
+ while pending_tasks:
+ done, pending_tasks = await asyncio.wait(pending_tasks, return_when=asyncio.FIRST_COMPLETED)
+ for completed_task in done:
+ result = await check_response(completed_task)
+
+ if result is not None:
+ # Return the first successful result
+ if (result_hidden_params := get_hidden_params(result)) is not None:
+ result_hidden_params["fastest_response_batch_completion"] = True
+ return result
+
+ # If we exit the loop without returning, all tasks failed
+ raise Exception("All tasks failed")
+
+ ### SCHEDULER ###
+
+ # fmt: off
+
+ @overload
+ async def schedule_acompletion(
+ self,
+ model: str,
+ messages: list[AllMessageValues],
+ priority: int | None = None,
+ stream: Literal[False] = False,
+ **kwargs,
+ ) -> ModelResponse:
+ ...
+
+ @overload
+ async def schedule_acompletion(
+ self, model: str, messages: list[AllMessageValues], priority: int | None, stream: Literal[True], **kwargs
+ ) -> CustomStreamWrapper:
+ ...
+
+ # fmt: on
+
+ async def schedule_acompletion(
+ self,
+ model: str,
+ messages: list[AllMessageValues],
+ priority: int | None = None,
+ stream=False,
+ **kwargs,
+ ):
+ queued_priority, _ = self._core_split_request_priority(model=model, kwargs={**kwargs, "priority": priority})
+ if queued_priority is None:
+ raise priority_error(model=model, message=unqueueable_priority_message(priority))
+ return await self.acompletion(model=model, messages=messages, stream=stream, priority=queued_priority, **kwargs)
+
+ def _core_split_request_priority(
+ self, model: str, kwargs: Mapping[str, object]
+ ) -> tuple[int | None, Mapping[str, object]]:
+ request_priority: Final = resolve_request_priority(
+ requested=kwargs.get("priority"),
+ default_priority=self.default_priority,
+ drops_params=lambda: self._drops_params(model=model, kwargs=kwargs),
+ )
+ if isinstance(request_priority, InvalidPriority):
+ raise priority_error(model=model, message=request_priority.message)
+ return request_priority, {key: value for key, value in kwargs.items() if key != "priority"}
+
+ def priority_header_update(
+ self, model: str, kwargs: Mapping[str, object], header: str | None
+ ) -> Mapping[str, int | str]:
+ return priority_header_fields(header, drops_params=lambda: self._drops_params(model=model, kwargs=kwargs))
+
+ def _drops_params(self, model: str, kwargs: Mapping[str, object]) -> bool:
+ return request_drops_params(
+ kwargs, self._core_default_litellm_params, self._request_deployment_params(model=model, kwargs=kwargs)
+ )
+
+ async def _schedule_factory(
+ self,
+ model: str,
+ priority: int,
+ original_function: Callable[..., Awaitable[object]],
+ args: tuple[object, ...],
+ kwargs: Mapping[str, object],
+ ):
+ await self._wait_for_scheduler_turn(
+ model=model,
+ priority=priority,
+ parent_otel_span=get_parent_otel_span_from_kwargs(kwargs),
+ request_kwargs=kwargs,
+ )
+ try:
+ _response: Final = await original_function(*args, **kwargs)
+ response_hidden_params: Final = get_hidden_params(_response)
+ if response_hidden_params is not None:
+ additional_headers: Final = cast( # cast-ok: router headers are stored as a mutable mapping
+ dict[str, object], response_hidden_params.setdefault("additional_headers", {})
+ )
+ additional_headers.update({"x-litellm-request-prioritization-used": True})
+ return _response
+ except Exception as e:
+ setattr(e, "priority", priority)
+ raise e
+
+ async def _wait_for_scheduler_turn(
+ self, model: str, priority: int, parent_otel_span: Span | None, request_kwargs: Mapping[str, object]
+ ) -> None:
+ pinned: Final = self._pinned_deployment(model)
+
+ async def healthy_deployments() -> Sequence[object]:
+ if pinned is not None:
+ return (pinned,)
+ deployments, _ = await self._async_get_healthy_deployments(
+ model=model,
+ parent_otel_span=parent_otel_span,
+ request_kwargs=share_holder_request_kwargs(request_kwargs, self._ptu_share_teams()),
+ )
+ return deployments
+
+ await self.scheduler.wait_for_turn(
+ request=FlowItem(priority=priority, request_id=str(uuid.uuid4()), model_name=model),
+ timeout=self.timeout,
+ get_healthy_deployments=healthy_deployments,
+ )
+
+ def _is_prompt_management_model(self, model: str) -> bool:
+ model_list: Final = self._core_get_model_list(model_name=model)
+ if model_list is None or len(model_list) != 1:
+ return False
+
+ litellm_model: Final = model_list[0]["litellm_params"].get("model", None)
+ if litellm_model is None or "/" not in litellm_model:
+ return False
+
+ split_litellm_model: Final = litellm_model.split("/")[0]
+ return split_litellm_model in self._host.dynamic().known_custom_logger_compatible_callbacks
+
+ def _pinned_deployment(self, model: str) -> Deployment | None:
+ return self._core_get_deployment(model_id=model) if model not in self._core_model_names else None
+
+ def _request_deployment_params(self, model: str, kwargs: Mapping[str, object]) -> tuple[Mapping[str, object], ...]:
+ deployment: Final = self._pinned_deployment(model)
+ if deployment is not None:
+ return (deployment.litellm_params.model_dump(exclude_none=True),)
+ candidates: Final = (
+ self._get_deployment_by_litellm_model(model=model)
+ if kwargs.get("specific_deployment") is True
+ else self._core_get_model_list(model_name=model, team_id=get_request_team_id(kwargs)) or ()
+ )
+ return tuple(candidate["litellm_params"] for candidate in candidates)
+
+ def _prompt_management_acompletion(
+ self,
+ model: str,
+ messages: list[AllMessageValues], # mutable-ok: _prompt_management_factory takes the caller's list
+ ) -> Callable[..., Awaitable[object]]:
+ async def acompletion(**kwargs: object) -> object: # kwargs-ok: called with the request's own keyword payload
+ return await self._prompt_management_factory(model=model, messages=messages, kwargs=dict(kwargs))
+
+ return acompletion
+
+ async def _prompt_management_factory(
+ self,
+ model: str,
+ messages: list[AllMessageValues],
+ kwargs: dict[str, Any],
+ ):
+ litellm_logging_object = kwargs.get("litellm_logging_obj", None)
+ if litellm_logging_object is None:
+ litellm_logging_object, kwargs = function_setup(
+ **{
+ "original_function": "acompletion",
+ "rules_obj": Rules(),
+ "start_time": get_utc_datetime(),
+ **kwargs,
+ }
+ )
+ litellm_logging_object = cast(LiteLLMLogging, litellm_logging_object)
+ caller_timeout: Final = kwargs.get("timeout") or kwargs.get("request_timeout")
+ caller_stream_timeout: Final = kwargs.get("stream_timeout")
+ specific_deployment: Final = kwargs.pop("specific_deployment", None)
+ prompt_management_deployment: Final = await self._core_async_get_available_deployment(
+ model=model,
+ messages=cast(list[dict[str, str]], messages), # cast-ok: selection reads messages structurally
+ specific_deployment=specific_deployment,
+ request_kwargs=kwargs,
+ )
+
+ self._update_kwargs_with_deployment(
+ deployment=prompt_management_deployment, kwargs=kwargs, splits_stream_timeout=True
+ )
+ data: Final = prompt_management_deployment["litellm_params"].copy()
+
+ litellm_model: Final = data.get("model", None)
+
+ # litellm_agent/ prefix only strips the model name, no prompt_id needed
+ is_litellm_agent_model: Final = isinstance(litellm_model, str) and litellm_model.startswith("litellm_agent/")
+
+ prompt_id = kwargs.get("prompt_id") or prompt_management_deployment["litellm_params"].get("prompt_id", None)
+ prompt_variables: Final = kwargs.get("prompt_variables") or prompt_management_deployment["litellm_params"].get(
+ "prompt_variables", None
+ )
+ prompt_label: Final = kwargs.get("prompt_label", None) or prompt_management_deployment["litellm_params"].get(
+ "prompt_label", None
+ )
+
+ if not is_litellm_agent_model and (prompt_id is None or not isinstance(prompt_id, str)):
+ raise ValueError(f"Prompt ID is not set or not a string. Got={prompt_id}, type={type(prompt_id)}")
+ if prompt_variables is not None and not isinstance(prompt_variables, dict):
+ raise ValueError(
+ f"Prompt variables is set but not a dictionary. Got={prompt_variables}, type={type(prompt_variables)}"
+ )
+
+ (
+ model,
+ messages,
+ optional_params,
+ ) = litellm_logging_object.get_chat_completion_prompt(
+ model=litellm_model,
+ messages=messages,
+ non_default_params=get_non_default_completion_params(kwargs=kwargs),
+ prompt_id=prompt_id,
+ prompt_variables=prompt_variables,
+ prompt_label=prompt_label,
+ request_kwargs=kwargs,
+ injected_for_every_deployment=True,
+ )
+
+ # Filter out prompt management specific parameters from data before merging
+ prompt_management_params: Final = {
+ "bitbucket_config",
+ "dotprompt_config",
+ "prompt_id",
+ "prompt_variables",
+ "prompt_label",
+ "prompt_version",
+ }
+ filtered_data: Final = {k: v for k, v in data.items() if k not in prompt_management_params}
+
+ kwargs = {**filtered_data, **kwargs, **optional_params}
+ kwargs["model"] = model
+ kwargs["messages"] = messages
+ kwargs["litellm_logging_obj"] = litellm_logging_object
+ kwargs["prompt_id"] = prompt_id
+ kwargs["prompt_variables"] = prompt_variables
+ kwargs["prompt_label"] = prompt_label
+
+ _model_list: Final = self._core_get_model_list(model_name=model)
+ if _model_list is None or len(_model_list) == 0: # if direct call to model
+ kwargs.pop("original_function")
+ return await self._host.invoke("acompletion")(**kwargs)
+
+ target_group_timeouts: Final = {
+ key: value
+ for key, value in (
+ ("timeout", caller_timeout if caller_timeout is not None else filtered_data.get("timeout")),
+ (
+ "stream_timeout",
+ caller_stream_timeout if caller_stream_timeout is not None else filtered_data.get("stream_timeout"),
+ ),
+ )
+ if value is not None
+ }
+ return await self.async_function_with_fallbacks(
+ **{key: value for key, value in kwargs.items() if key not in ("timeout", "stream_timeout")},
+ **target_group_timeouts,
+ )
+
+ def image_generation(self, prompt: str, model: str, **kwargs):
+ try:
+ kwargs["model"] = model
+ kwargs["prompt"] = prompt
+ kwargs["original_function"] = self._image_generation
+ kwargs.setdefault("metadata", {}).update({"model_group": model})
+ response: Final = self.function_with_fallbacks(**kwargs)
+
+ return response
+ except Exception as e:
+ raise e
+
+ def _image_generation(self, prompt: str, model: str, **kwargs):
+ model_name: Final = ""
+ try:
+ verbose_router_logger.debug("Inside _image_generation()- model: %s", model)
+ deployment: Final = self.get_available_deployment(
+ model=model,
+ messages=[{"role": "user", "content": "prompt"}],
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+ self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
+ data: Final = deployment["litellm_params"].copy()
+
+ model_client: Final = self._get_async_openai_model_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ )
+
+ self.total_calls[model_name] += 1
+
+ ### DEPLOYMENT-SPECIFIC PRE-CALL CHECKS ### (e.g. update rpm pre-call. Raise error, if deployment over limit)
+ self.routing_strategy_pre_call_checks(deployment=deployment)
+
+ response: Final = self._host.invoke("image_generation")(
+ **{
+ **data,
+ "prompt": prompt,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.image_generation(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+ return response
+ except Exception as e:
+ verbose_router_logger.info("litellm.image_generation(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
+ if model_name is not None:
+ self.fail_calls[model_name] += 1
+ self._stamp_retry_skip_deployment_id(e, kwargs)
+ raise e
+
+ async def aimage_generation(self, prompt: str, model: str, **kwargs):
+ try:
+ kwargs["model"] = model
+ kwargs["prompt"] = prompt
+ kwargs["original_function"] = self._aimage_generation
+ self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
+ response: Final = await self.async_function_with_fallbacks(**kwargs)
+
+ return response
+ except Exception as e:
+ asyncio.create_task(
+ send_llm_exception_alert(
+ ctx=self._core_ctx,
+ request_kwargs=kwargs,
+ error_traceback_str=traceback.format_exc(),
+ original_exception=e,
+ )
+ )
+ raise e
+
+ async def _aimage_generation(self, prompt: str, model: str, **kwargs):
+ model_name = model
+ start_time: Final = datetime.now() # noqa: DTZ005 # SDK logging uses naive local timestamps
+ try:
+ verbose_router_logger.debug("Inside _image_generation()- model: %s", model)
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ try:
+ deployment: Final = await self._core_async_get_available_deployment(
+ model=model,
+ messages=[{"role": "user", "content": "prompt"}],
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+ except Exception as selection_error:
+ # Admitted attempts keep SDK-owned logging, including deployment callbacks and retry state
+ request_kwargs: Final = cast( # cast-ok: Python kwargs have string keys and opaque values
+ Mapping[str, object], kwargs
+ )
+ if request_kwargs.get("litellm_logging_obj") is None:
+ refusal_kwargs: Final = {**request_kwargs, "model": model, "prompt": prompt}
+ refusal_kwargs.setdefault("litellm_call_id", str(uuid.uuid4()))
+ logging_obj, _ = function_setup(
+ "aimage_generation",
+ Rules(),
+ start_time,
+ **refusal_kwargs, # pyright: ignore[reportArgumentType] # preserve opaque SDK options, including is_async_call
+ )
+ try:
+ asyncio.create_task(
+ logging_obj.dispatch_failure_handlers(
+ exception=selection_error,
+ traceback_exception=traceback.format_exc(),
+ prefer_async_handlers=True,
+ )
+ )
+ finally:
+ _restore_correlation_context_if_supported(logging_obj)
+ raise
+ self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
+
+ data: Final = deployment["litellm_params"].copy()
+ model_name = data["model"]
+
+ model_client: Final = self._get_async_openai_model_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ )
+
+ self.total_calls[model_name] += 1
+ async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
+ response = await self._host.invoke("aimage_generation")(
+ **{
+ **data,
+ "prompt": prompt,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.aimage_generation(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+ return response
+ except Exception as e:
+ verbose_router_logger.info("litellm.aimage_generation(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
+ if model_name is not None:
+ self.fail_calls[model_name] += 1
+ self._stamp_retry_skip_deployment_id(e, kwargs)
+ raise e
+
+ async def atranscription(self, file: FileTypes, model: str, **kwargs):
+ """
+ Example Usage:
+
+ ```
+ from litellm import Router
+ client = Router(model_list = [
+ {
+ "model_name": "whisper",
+ "litellm_params": {
+ "model": "whisper-1",
+ },
+ },
+ ])
+
+ audio_file = open("speech.mp3", "rb")
+ transcript = await client.atranscription(
+ model="whisper",
+ file=audio_file
+ )
+
+ ```
+ """
+ try:
+ kwargs["model"] = model
+ kwargs["file"] = file
+ kwargs["original_function"] = self._atranscription
+ self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
+ response: Final = await self.async_function_with_fallbacks(**kwargs)
+
+ return response
+ except Exception as e:
+ asyncio.create_task(
+ send_llm_exception_alert(
+ ctx=self._core_ctx,
+ request_kwargs=kwargs,
+ error_traceback_str=traceback.format_exc(),
+ original_exception=e,
+ )
+ )
+ raise e
+
+ async def _atranscription(self, file: FileTypes, model: str, **kwargs):
+ model_name: Final = model
+ try:
+ verbose_router_logger.debug("Inside _atranscription()- model: %s", model)
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ deployment: Final = await self._core_async_get_available_deployment(
+ model=model,
+ messages=[{"role": "user", "content": "prompt"}],
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+
+ self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
+ data: Final = deployment["litellm_params"].copy()
+ model_client: Final = self._get_async_openai_model_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ )
+
+ self.total_calls[model_name] += 1
+ async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
+ response = await self._host.invoke("atranscription")(
+ **{
+ **data,
+ "file": file,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.atranscription(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+ return response
+ except Exception as e:
+ verbose_router_logger.info("litellm.atranscription(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
+ if model_name is not None:
+ self.fail_calls[model_name] += 1
+ self._stamp_retry_skip_deployment_id(e, kwargs)
+ raise e
+
+ async def aspeech(self, model: str, input: str, voice: str | None = None, **kwargs):
+ """
+ Example Usage:
+
+ ```
+ from litellm import Router
+ client = Router(model_list = [
+ {
+ "model_name": "tts",
+ "litellm_params": {
+ "model": "tts-1",
+ },
+ },
+ ])
+
+ async with client.aspeech(
+ model="tts",
+ voice="alloy",
+ input="the quick brown fox jumped over the lazy dogs",
+ api_base=None,
+ api_key=None,
+ organization=None,
+ project=None,
+ max_retries=1,
+ timeout=600,
+ client=None,
+ optional_params={},
+ ) as response:
+ response.stream_to_file(speech_file_path)
+
+ ```
+ """
+ try:
+ kwargs["model"] = model
+ kwargs["input"] = input
+ kwargs["voice"] = voice
+ kwargs["original_function"] = self._aspeech
+ self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
+ response: Final = await self.async_function_with_fallbacks(**kwargs)
+
+ return response
+ except Exception as e:
+ asyncio.create_task(
+ send_llm_exception_alert(
+ ctx=self._core_ctx,
+ request_kwargs=kwargs,
+ error_traceback_str=traceback.format_exc(),
+ original_exception=e,
+ )
+ )
+ raise e
+
+ async def _aspeech(self, model: str, input: str, voice: str | None = None, **kwargs):
+ model_name: Final = model
+ try:
+ verbose_router_logger.debug("Inside _aspeech()- model: %s", model)
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ deployment: Final = await self._core_async_get_available_deployment(
+ model=model,
+ messages=[{"role": "user", "content": "prompt"}],
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+
+ self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
+ data: Final = deployment["litellm_params"].copy()
+ model_client: Final = self._get_async_openai_model_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ )
+
+ self.total_calls[model_name] += 1
+ async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
+ response = await self._host.invoke("aspeech")(
+ **{
+ **data,
+ "input": input,
+ "voice": data.get("voice") if voice is None else voice,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.aspeech(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+ return response
+ except Exception as e:
+ verbose_router_logger.info("litellm.aspeech(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
+ if model_name is not None:
+ self.fail_calls[model_name] += 1
+ self._stamp_retry_skip_deployment_id(e, kwargs)
+ raise e
+
+ async def arerank(self, model: str, **kwargs):
+ try:
+ kwargs["model"] = model
+ kwargs["input"] = input
+ kwargs["original_function"] = self._arerank
+ self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
+
+ response: Final = await self.async_function_with_fallbacks(**kwargs)
+
+ return response
+ except Exception as e:
+ asyncio.create_task(
+ send_llm_exception_alert(
+ ctx=self._core_ctx,
+ request_kwargs=kwargs,
+ error_traceback_str=traceback.format_exc(),
+ original_exception=e,
+ )
+ )
+ raise e
+
+ async def _arerank(self, model: str, **kwargs):
+ model_name = None
+ try:
+ verbose_router_logger.debug("Inside _rerank()- model: %s", model)
+ deployment: Final = await self._core_async_get_available_deployment(
+ model=model,
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+ self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
+ data: Final = deployment["litellm_params"].copy()
+ model_name = data["model"]
+
+ model_client: Final = self._get_async_openai_model_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ )
+ self.total_calls[model_name] += 1
+
+ response: Final = await self._host.invoke("arerank")(
+ **{
+ **data,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.arerank(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+ return response
+ except Exception as e:
+ verbose_router_logger.info("litellm.arerank(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
+ if model_name is not None:
+ self.fail_calls[model_name] += 1
+ self._stamp_retry_skip_deployment_id(e, kwargs)
+ raise e
+
+ def text_completion(
+ self,
+ model: str,
+ prompt: str,
+ is_retry: bool | None = False,
+ is_fallback: bool | None = False,
+ is_async: bool | None = False,
+ **kwargs,
+ ):
+ messages: Final = [{"role": "user", "content": prompt}]
+ try:
+ kwargs["model"] = model
+ kwargs["prompt"] = prompt
+ kwargs["num_retries"] = kwargs.get("num_retries", self.num_retries)
+ kwargs.setdefault("metadata", {}).update({"model_group": model})
+
+ # pick the one that is available (lowest TPM/RPM)
+ with endpoint_selection("text_completion", model=model):
+ deployment: Final = self.get_available_deployment(
+ model=model,
+ messages=messages,
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+
+ data: Final = deployment["litellm_params"].copy()
+ for k, v in self._core_default_litellm_params.items():
+ if k not in kwargs: # prioritize model-specific params > default router params
+ kwargs[k] = v
+ elif k == "metadata":
+ kwargs[k].update(v)
+ pinned_model_id: Final = deployment_model_id_pin(_LITELLM_PARAMS_ADAPTER.validate_python(data))
+ if pinned_model_id is not None:
+ kwargs["model_id"] = pinned_model_id # rebind-ok: this call's own kwargs, filled like the defaults
+
+ # call via litellm.completion()
+ return self._host.invoke("text_completion")(
+ **{**data, "prompt": prompt, "caching": self._core_cache_responses, **kwargs}
+ )
+ except Exception as e:
+ raise e
+
+ async def atext_completion(
+ self,
+ model: str,
+ prompt: str,
+ is_retry: bool | None = False,
+ is_fallback: bool | None = False,
+ is_async: bool | None = False,
+ **kwargs,
+ ):
+ try:
+ kwargs["model"] = model
+ kwargs["prompt"] = prompt
+ kwargs["original_function"] = self._atext_completion
+
+ self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
+ request_priority, request_kwargs = self._core_split_request_priority(model=model, kwargs=kwargs)
+ if request_priority is None:
+ return await self.async_function_with_fallbacks(**request_kwargs)
+ return await self._schedule_factory(
+ model=model,
+ priority=request_priority,
+ original_function=self.async_function_with_fallbacks,
+ args=(),
+ kwargs=request_kwargs,
+ )
+ except Exception as e:
+ asyncio.create_task(
+ send_llm_exception_alert(
+ ctx=self._core_ctx,
+ request_kwargs=kwargs,
+ error_traceback_str=traceback.format_exc(),
+ original_exception=e,
+ )
+ )
+ raise e
+
+ async def _atext_completion(self, model: str, prompt: str, **kwargs):
+ try:
+ verbose_router_logger.debug("Inside _atext_completion()- model: %s", model)
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ deployment: Final = await self._core_async_get_available_deployment(
+ model=model,
+ messages=[{"role": "user", "content": prompt}],
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+ self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, splits_stream_timeout=True)
+
+ data: Final = deployment["litellm_params"].copy()
+ model_name: Final = data["model"]
+
+ model_client: Final = self._get_async_openai_model_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ )
+ self.total_calls[model_name] += 1
+
+ async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
+ response = await self._host.invoke("atext_completion")(
+ **{
+ **data,
+ "prompt": prompt,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.atext_completion(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+ return response
+ except Exception as e:
+ verbose_router_logger.info("litellm.atext_completion(model=%s)\x1b[31m Exception %s\x1b[0m", model, e)
+ if model is not None:
+ self.fail_calls[model] += 1
+ self._stamp_retry_skip_deployment_id(e, kwargs)
+ raise e
+
+ async def aadapter_completion(
+ self,
+ adapter_id: str,
+ model: str,
+ is_retry: bool | None = False,
+ is_fallback: bool | None = False,
+ is_async: bool | None = False,
+ **kwargs,
+ ):
+ try:
+ kwargs["model"] = model
+ kwargs["adapter_id"] = adapter_id
+ kwargs["original_function"] = self._aadapter_completion
+ kwargs.setdefault("metadata", {}).update({"model_group": model})
+ response: Final = await self.async_function_with_fallbacks(**kwargs)
+
+ return response
+ except Exception as e:
+ asyncio.create_task(
+ send_llm_exception_alert(
+ ctx=self._core_ctx,
+ request_kwargs=kwargs,
+ error_traceback_str=traceback.format_exc(),
+ original_exception=e,
+ )
+ )
+ raise e
+
+ async def _aadapter_completion(self, adapter_id: str, model: str, **kwargs):
+ try:
+ verbose_router_logger.debug("Inside _aadapter_completion()- model: %s", model)
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ deployment: Final = await self._core_async_get_available_deployment(
+ model=model,
+ messages=[{"role": "user", "content": "default text"}],
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+ self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, splits_stream_timeout=True)
+
+ data: Final = deployment["litellm_params"].copy()
+ model_name: Final = data["model"]
+
+ model_client: Final = self._get_async_openai_model_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ )
+ self.total_calls[model_name] += 1
+
+ async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
+ response = await self._host.invoke("aadapter_completion")(
+ **{
+ **data,
+ "adapter_id": adapter_id,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.aadapter_completion(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+ return response
+ except Exception as e:
+ verbose_router_logger.info("litellm.aadapter_completion(model=%s)\x1b[31m Exception %s\x1b[0m", model, e)
+ if model is not None:
+ self.fail_calls[model] += 1
+ self._stamp_retry_skip_deployment_id(e, kwargs)
+ raise e
+
+ async def _asearch_with_fallbacks(self, original_function: Callable, **kwargs) -> object:
+ """
+ Helper function to make a search API call through the router with load balancing and fallbacks.
+ Reuses the router's retry/fallback infrastructure.
+ """
+ from litellm.router_utils.search_api_router import SearchAPIRouter
+
+ return _OPAQUE_VALUE_ADAPTER.validate_python(
+ await SearchAPIRouter.async_search_with_fallbacks(
+ litellm_routing_ctx=self._core_ctx,
+ original_function=original_function,
+ **kwargs,
+ )
+ )
+
+ async def _asearch_with_fallbacks_helper(self, model: str, original_generic_function: Callable, **kwargs):
+ """
+ Helper function for search API calls - selects a search tool and calls the original function.
+ Called by async_function_with_fallbacks for each retry attempt.
+ """
+ from litellm.router_utils.search_api_router import SearchAPIRouter
+
+ return await SearchAPIRouter.async_search_with_fallbacks_helper(
+ litellm_routing_ctx=self._core_ctx,
+ model=model,
+ original_generic_function=original_generic_function,
+ **kwargs,
+ )
+
+ async def aguardrail(
+ self,
+ guardrail_name: str,
+ original_function: Callable,
+ **kwargs,
+ ):
+ """
+ Execute a guardrail with load balancing and fallbacks.
+
+ Args:
+ guardrail_name: Name of the guardrail to execute
+ original_function: The guardrail's execution function (e.g., async_pre_call_hook)
+ **kwargs: Additional arguments passed to the guardrail
+
+ Returns:
+ Result from the guardrail execution
+ """
+ kwargs["model"] = guardrail_name # For fallback system compatibility
+ kwargs["original_generic_function"] = original_function
+ kwargs["original_function"] = self._aguardrail_helper
+ self._update_kwargs_before_fallbacks(
+ model=guardrail_name,
+ kwargs=kwargs,
+ metadata_variable_name="litellm_metadata",
+ )
+ verbose_router_logger.debug("Inside aguardrail() - guardrail_name: %s", guardrail_name)
+ response: Final = await self.async_function_with_fallbacks(**kwargs)
+ return response
+
+ async def _aguardrail_helper(
+ self,
+ model: str,
+ original_generic_function: Callable,
+ **kwargs,
+ ):
+ """
+ Helper for aguardrail - selects a guardrail deployment and executes it.
+ Called by async_function_with_fallbacks for each retry attempt.
+
+ Args:
+ model: The guardrail_name (named 'model' for fallback system compatibility)
+ original_generic_function: The guardrail's execution function
+ **kwargs: Additional arguments
+ """
+ guardrail_name: Final = model
+ selected_guardrail: Final = self._core_get_available_guardrail(
+ guardrail_name=guardrail_name,
+ )
+
+ verbose_router_logger.debug(
+ "Selected guardrail deployment: %s", selected_guardrail.get("litellm_params", {}).get("guardrail")
+ )
+
+ # Pass the selected guardrail config to the original function
+ kwargs["selected_guardrail"] = selected_guardrail
+ response: Final = await original_generic_function(**kwargs)
+ return response
+
+ def _core_get_available_guardrail(
+ self,
+ guardrail_name: str,
+ ) -> "GuardrailTypedDict":
+ """
+ Select a guardrail deployment using the router's load balancing strategy.
+
+ Args:
+ guardrail_name: Name of the guardrail to select
+
+ Returns:
+ Selected guardrail configuration dict
+ """
+ from litellm.router_strategy.simple_shuffle import simple_shuffle
+
+ healthy_deployments: Final = [g for g in self._core_guardrail_list if g.get("guardrail_name") == guardrail_name]
+
+ if not healthy_deployments:
+ raise ValueError(f"No guardrail found with name: {guardrail_name}")
+
+ if len(healthy_deployments) == 1:
+ return healthy_deployments[0]
+
+ # Use simple_shuffle for weighted selection
+ return simple_shuffle(
+ resolve_model_alias=self._core_get_model_from_alias,
+ healthy_deployments=healthy_deployments,
+ model=guardrail_name,
+ request_kwargs=None,
+ )
+
+ async def _ageneric_api_call_with_fallbacks(
+ self, model: str, original_function: Callable, attempt_function: Callable | None = None, **kwargs
+ ):
+ """
+ Helper function to make a generic LLM API call through the router, this allows you to use retries/fallbacks with litellm router
+
+ attempt_function runs every attempt of the chain instead of the plain helper, so a streaming
+ endpoint can wrap each attempt's stream with its own mid-stream fallback handling.
+ """
+ try:
+ kwargs["model"] = model
+ kwargs["original_generic_function"] = original_function
+ kwargs["original_function"] = attempt_function or self._ageneric_api_call_with_fallbacks_helper
+ if attempt_function is not None:
+ controls: Final = per_request_fallback_controls(kwargs)
+ kwargs[MID_STREAM_FALLBACK_CONTROLS_KEY] = controls # rebind-ok: forwarded to every hop
+ self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs, metadata_variable_name="litellm_metadata")
+ verbose_router_logger.debug("Inside ageneric_api_call_with_fallbacks() - model: %s", model)
+ response: Final = await self.async_function_with_fallbacks(**kwargs)
+ return response
+ except Exception as e:
+ asyncio.create_task(
+ send_llm_exception_alert(
+ ctx=self._core_ctx,
+ request_kwargs=kwargs,
+ error_traceback_str=traceback.format_exc(),
+ original_exception=e,
+ )
+ )
+ raise e
+
+ def _add_deployment_model_to_endpoint_for_llm_passthrough_route(
+ self, kwargs: dict[str, Any], model: str, model_name: str
+ ) -> dict[str, Any]:
+ """
+ Add the deployment model to the endpoint for LLM passthrough route.
+
+ e.g for bedrock invoke users can pass endpoint as /model/special-bedrock-model/invoke
+ it should be actually sent as /model/us.anthropic.claude-3-5-sonnet-20240620-v1:0/invoke
+ """
+ if "endpoint" in kwargs and kwargs["endpoint"]:
+ # For provider-specific endpoints, strip the provider prefix from model_name
+ # e.g., "bedrock/us.anthropic.claude-3-5-sonnet-20240620-v1:0" -> "us.anthropic.claude-3-5-sonnet-20240620-v1:0"
+ from litellm import get_llm_provider
+
+ try:
+ # get_llm_provider returns (model_without_prefix, provider, api_key, api_base)
+ stripped_model_name, _, _, _ = get_llm_provider(
+ model=model_name,
+ custom_llm_provider=kwargs.get("custom_llm_provider"),
+ api_base=kwargs.get("api_base"),
+ )
+ replacement_model_name = stripped_model_name
+ except Exception:
+ # If get_llm_provider fails, fall back to using model_name as-is
+ replacement_model_name = model_name
+
+ kwargs["endpoint"] = replace_path_segment(kwargs["endpoint"], model, replacement_model_name)
+ return kwargs
+
+ async def _ageneric_api_call_with_fallbacks_helper(self, model: str, original_generic_function: Callable, **kwargs):
+ """
+ Helper function to make a generic LLM API call through the router, this allows you to use retries/fallbacks with litellm router
+ """
+
+ passthrough_on_no_deployment: Final = kwargs.pop("passthrough_on_no_deployment", False)
+ function_name: Final = "_ageneric_api_call_with_fallbacks"
+ handler_name: Final[str | None] = getattr(original_generic_function, "__name__", None)
+ deployment = None # rebind-ok: pre-init so the except block can stamp a failure with no deployment picked
+ try:
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ try:
+ with self._video_call_pin(handler_name, kwargs), self._route_capability_pin(handler_name):
+ deployment = ( # rebind-ok: set on success, see pre-init
+ await self._core_async_get_available_deployment(
+ model=model,
+ request_kwargs=kwargs,
+ messages=kwargs.get("messages", None),
+ input=kwargs.get("input", None),
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ )
+ )
+ except Exception as e:
+ if passthrough_on_no_deployment:
+ return await original_generic_function(model=model, **kwargs)
+ raise e
+
+ self._update_kwargs_with_deployment(
+ deployment=deployment,
+ kwargs=kwargs,
+ function_name=function_name,
+ splits_stream_timeout=handler_name in _STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS,
+ opens_responses_socket=handler_name == "_aresponses_websocket",
+ )
+
+ data: Final = deployment["litellm_params"].copy()
+ model_name: Final = data["model"]
+ self.total_calls[model_name] += 1
+
+ self._add_deployment_model_to_endpoint_for_llm_passthrough_route(
+ kwargs=kwargs, model=model, model_name=model_name
+ )
+
+ custom_llm_provider: Final = provider_for_generic_call(data)
+ generic_function_name: Final[str] = getattr(original_generic_function, "__name__", "")
+
+ response_kwargs: Final = {
+ **without_retrieve_stream_defaults(data, kwargs),
+ "caching": self._core_cache_responses,
+ **kwargs,
+ "model": model_name,
+ **_with_router_resolved_session_model(kwargs.get("session"), model_name),
+ **(
+ {"turn_admission": partial(self.async_admit_deployment_turn, model, deployment, kwargs)}
+ if generic_function_name == "_aresponses_websocket"
+ else {}
+ ),
+ }
+ # Only set custom_llm_provider if it's not None
+ if custom_llm_provider is not None:
+ response_kwargs["custom_llm_provider"] = custom_llm_provider
+
+ compacted_input: Final = await compact_to_fit(
+ self._core_ctx,
+ deployment,
+ response_kwargs,
+ surface_for_call(generic_function_name),
+ )
+ async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
+ response = await original_generic_function(**compacted_input)
+
+ if self._should_raise_anthropic_refusal_error(
+ model=model,
+ original_generic_function=original_generic_function,
+ response=response,
+ kwargs=kwargs,
+ ):
+ from litellm.llms.anthropic.messages.native.utils import (
+ safeguard_refusal_error,
+ )
+
+ refusal_details: Final = cast(dict, response["stop_details"]) # cast-ok: gate verified the shape
+ raise safeguard_refusal_error(model=model, stop_details=refusal_details)
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("ageneric_api_call_with_fallbacks(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+
+ return response
+ except Exception as e:
+ verbose_router_logger.info(
+ "ageneric_api_call_with_fallbacks(model=%s)\x1b[31m Exception %s\x1b[0m", model, e
+ )
+ if model is not None:
+ self.fail_calls[model] += 1
+ if deployment is not None:
+ self._set_deployment_num_retries_on_exception(e, deployment)
+ self._stamp_failed_deployment_id_with_effective_model_info(e, deployment, kwargs)
+ raise e
+
+ async def _aresponses_with_streaming_fallbacks(
+ self, original_function: Callable, **kwargs: Any
+ ) -> Union["ResponsesAPIResponse", "BaseResponsesAPIStreamingIterator"]:
+ """
+ _ageneric_api_call_with_fallbacks for the Responses API, with every attempt's stream
+ carrying its own mid-stream fallback handling
+ (see _ageneric_api_call_with_fallbacks_responses_attempt).
+ """
+ return await self._ageneric_api_call_with_fallbacks(
+ original_function=original_function,
+ attempt_function=self._ageneric_api_call_with_fallbacks_responses_attempt,
+ **kwargs,
+ )
+
+ async def _ageneric_api_call_with_fallbacks_responses_attempt(
+ self,
+ model: str,
+ original_generic_function: Callable,
+ **kwargs: object, # kwargs-ok: forwarded verbatim to the per-attempt helper, shape varies per call site
+ ) -> Union["ResponsesAPIResponse", "BaseResponsesAPIStreamingIterator"]:
+ """
+ One attempt of the Responses API fallback chain. A streaming result is wrapped with
+ _aresponses_streaming_iterator over this attempt's own kwargs, so a fallback hop that
+ fails mid-stream resumes the original group's chain instead of re-raising; the name keeps
+ _get_router_metadata_variable_name resolving to litellm_metadata for every hop.
+ """
+ from litellm.responses.streaming_iterator import (
+ BaseResponsesAPIStreamingIterator,
+ )
+
+ controls: Final = kwargs.pop(MID_STREAM_FALLBACK_CONTROLS_KEY, None)
+ hop_kwargs: Final = mid_stream_fallback_hop_kwargs(
+ model=model, original_generic_function=original_generic_function, controls=controls, kwargs=kwargs
+ )
+ shared_logging: Final = kwargs.get("litellm_logging_obj")
+ if isinstance(shared_logging, LiteLLMLogging):
+ shared_logging.carry_failed_attempt_usage()
+ response: Final = await self._ageneric_api_call_with_fallbacks_helper(
+ model=model, original_generic_function=original_generic_function, **kwargs
+ )
+ carry_over_pre_routing_selection(live_kwargs=kwargs, snapshot=hop_kwargs)
+ carry_over_routed_deployment(live_kwargs=kwargs, snapshot=hop_kwargs)
+ if kwargs.get("stream") and isinstance(response, BaseResponsesAPIStreamingIterator):
+ return await self._aresponses_streaming_iterator(response=response, initial_kwargs=hop_kwargs)
+ return response
+
+ async def _aanthropic_messages_streaming_iterator(
+ self,
+ response: AsyncIterator[bytes],
+ initial_kwargs: dict[str, object], # mutable-ok: mutated in-place before re-entering the fallback chain
+ ) -> AsyncIterator[bytes]:
+ """
+ Wrap an anthropic_messages (/v1/messages) streaming response so a
+ mid-stream provider error triggers the Router's fallback chain
+ (parity with _acompletion_streaming_iterator for the
+ chat-completions path). See #24004.
+
+ anthropic_messages goes through _ageneric_api_call_with_fallbacks
+ rather than _acompletion, so the returned byte iterator is never
+ wrapped by the chat-completions fallback handler. Two failure
+ shapes land here:
+ - the completion-bridge path (deployments with no native
+ /v1/messages endpoint, via
+ LiteLLMMessagesToCompletionTransformationHandler) already
+ raises MidStreamFallbackError out of its underlying
+ CustomStreamWrapper; this wrapper only needs to catch it.
+ - a native Anthropic/Bedrock passthrough never raises anything
+ for a provider SSE `event: error` frame (e.g. `overloaded_error`,
+ `internal_server_error`) - it is forwarded to the client as-is -
+ so this wrapper detects it via parse_anthropic_error_event and
+ raises MidStreamFallbackError itself.
+
+ Only an error before any real content (a content_block_delta frame)
+ has reached the caller triggers a fallback attempt, mirroring the
+ restriction _acompletion_streaming_iterator applies: once generated
+ output has already reached the caller, retrying would start a
+ second, overlapping Anthropic message lifecycle on the same SSE
+ stream, so the error is left to propagate instead of being retried
+ invisibly. A non-retriable client error (4xx other than 429) is
+ never worth a fallback attempt either, so it is also left to
+ propagate.
+
+ Lifecycle/bookkeeping frames (message_start, content_block_start,
+ ping, ...) do not by themselves disqualify a fallback attempt -
+ Anthropic routinely sends message_start before an overload error.
+ When a fallback can still take over they are BUFFERED rather than
+ forwarded immediately, since forwarding one and then appending a
+ fallback attempt's own message_start would produce two overlapping
+ message lifecycles on one SSE stream; a `ping` carries no lifecycle,
+ so it is forwarded live even while lifecycle frames sit buffered,
+ keeping the connection alive during a long thinking pass. Buffered
+ frames are flushed, in order, the moment real content arrives (the
+ primary attempt has committed by then anyway) or once the stream
+ ends without ever producing content or an error. When no fallback
+ can take over the request is already committed, so every frame,
+ including pings and provider error frames, is forwarded live and
+ verbatim instead.
+ """
+ from litellm.llms.anthropic.messages.native.fake_stream_iterator import (
+ FakeAnthropicMessagesStreamIterator,
+ )
+ from litellm.llms.anthropic.messages.native.streaming_iterator import (
+ aclose_if_supported,
+ aclose_superseded_attempt,
+ parse_anthropic_error_event,
+ parse_anthropic_refusal_stop_details,
+ )
+ from litellm.llms.anthropic.messages.native.utils import (
+ safeguard_refusal_error,
+ )
+
+ source_iterator: Final = response
+ replays_a_finished_agentic_turn: Final = isinstance(source_iterator, FakeAnthropicMessagesStreamIterator)
+
+ async def close_source() -> None:
+ with anyio.CancelScope(shield=True), contextlib.suppress(BaseException):
+ await aclose_if_supported(source_iterator)
+
+ async def close_superseded() -> None:
+ with anyio.CancelScope(shield=True), contextlib.suppress(BaseException):
+ await aclose_superseded_attempt(source_iterator)
+
+ async def stream_with_fallbacks() -> AsyncGenerator[bytes, None]:
+ from litellm.exceptions import MidStreamFallbackError
+
+ # Lifecycle/bookkeeping frames (message_start, content_block_start,
+ # ...) are held back rather than forwarded immediately, but only
+ # while a fallback can still take over: Anthropic routinely sends
+ # message_start before an overload error, and once a byte reaches
+ # the client a fallback attempt can only append its OWN
+ # message_start, producing two overlapping message lifecycles on
+ # one SSE stream. A `ping` keepalive carries no lifecycle, so it
+ # is forwarded live even behind buffered frames, keeping the
+ # connection alive through a long thinking pass. Buffered frames
+ # are flushed the moment real content (content_block_delta)
+ # arrives - at that point the primary attempt has committed and a
+ # clean retry is no longer possible anyway - or once the primary
+ # stream ends without ever producing content. Hitting
+ # MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS forces the same early
+ # commit as real content arriving, so a hostile or pathological
+ # upstream can't grow the buffer forever. With no fallback able
+ # to take over there is nothing to buffer for, so every frame,
+ # including pings and provider error frames, is forwarded live.
+ model: Final = cast(str, initial_kwargs.get("model")) # cast-ok: kwargs always carries the model group
+ has_generated_content = not ( # rebind-ok: set once real content is seen, the buffer cap is hit, or neither a retry nor a fallback can take over
+ self._anthropic_messages_stream_can_retry(initial_kwargs)
+ or self._stream_can_fall_back(model, initial_kwargs)
+ )
+ buffered_lifecycle_chunks: tuple[bytes, ...] = () # rebind-ok: flushed once committed or on decline
+ try:
+ async for chunk in source_iterator:
+ if _anthropic_stream_forwards_ping_live(chunk, has_generated_content):
+ yield chunk
+ continue
+ if _stream_commits_now(chunk, has_generated_content, len(buffered_lifecycle_chunks)):
+ has_generated_content = True
+ # A transport can split one SSE data line across byte chunks, so pre-content
+ # detection parses the accumulated buffer plus the current chunk, never the
+ # chunk alone; the buffer is already capped, which bounds this window too.
+ parse_window = (
+ b"".join(c for c in (*buffered_lifecycle_chunks, chunk) if isinstance(c, (bytes, bytearray))) # pyright: ignore[reportUnnecessaryIsInstance] # bridge-path chunks are not always bytes at runtime
+ if not has_generated_content and isinstance(chunk, (bytes, bytearray)) # pyright: ignore[reportUnnecessaryIsInstance] # bridge-path chunks are not always bytes at runtime
+ else chunk
+ )
+ error_event = parse_anthropic_error_event(parse_window)
+ recoverable_frame_error = self._anthropic_messages_recoverable_frame_error(
+ error_event, chunk, has_generated_content, model, initial_kwargs
+ )
+ refusal_stop_details = (
+ parse_anthropic_refusal_stop_details(parse_window)
+ if not has_generated_content and error_event is None
+ else None
+ )
+ if (
+ refusal_stop_details is not None
+ and not replays_a_finished_agentic_turn
+ and self._refusal_fallback_available(model, initial_kwargs)
+ ):
+ refusal_error = safeguard_refusal_error(model=model, stop_details=refusal_stop_details)
+ raise MidStreamFallbackError(
+ message=refusal_error.message,
+ model=model,
+ llm_provider="anthropic",
+ original_exception=refusal_error,
+ is_pre_first_chunk=True,
+ )
+ if not has_generated_content and error_event is None:
+ buffered_lifecycle_chunks = (*buffered_lifecycle_chunks, chunk)
+ continue
+ if recoverable_frame_error is not None:
+ assert error_event is not None
+ raise MidStreamFallbackError(
+ message=error_event[1],
+ model=model,
+ llm_provider="anthropic",
+ original_exception=recoverable_frame_error,
+ is_pre_first_chunk=True,
+ )
+ for buffered_chunk in buffered_lifecycle_chunks:
+ yield buffered_chunk
+ buffered_lifecycle_chunks = ()
+ yield chunk
+ for buffered_chunk in buffered_lifecycle_chunks:
+ yield buffered_chunk
+ except Exception as stream_error: # noqa: BLE001 # any raised provider error must reach the fallback gate
+ async for item in self._aanthropic_messages_recover_stream_error(
+ stream_error,
+ has_generated_content,
+ buffered_lifecycle_chunks,
+ model,
+ initial_kwargs,
+ wrapper,
+ close_superseded,
+ ):
+ yield item
+ finally:
+ await close_source()
+
+ # Referenced by stream_with_fallbacks via closure - assigned here, before
+ # the generator body ever runs, so the reference resolves fine despite
+ # being defined textually after the function that captures it.
+ wrapper: Final = FallbackAwareAnthropicMessagesStream(stream_with_fallbacks(), source_iterator)
+ return wrapper
+
+ async def _aanthropic_messages_recover_stream_error(
+ self,
+ stream_error: Exception,
+ has_generated_content: bool,
+ buffered_lifecycle_chunks: tuple[bytes, ...],
+ model: str,
+ initial_kwargs: dict[str, object], # mutable-ok: _aanthropic_messages_fallback_attempt mutates it
+ wrapper: "FallbackAwareAnthropicMessagesStream",
+ close_superseded: Callable[[], Awaitable[None]],
+ ) -> AsyncGenerator[bytes, None]:
+ """Turns a source-iterator failure into a fallback attempt or the error reaching the caller."""
+ from litellm.exceptions import MidStreamFallbackError
+
+ if isinstance(stream_error, MidStreamFallbackError) and _anthropic_stream_should_decline_fallback(
+ has_generated_content, stream_error
+ ):
+ for buffered_chunk in buffered_lifecycle_chunks:
+ yield buffered_chunk
+ if stream_error.original_exception is not None:
+ raise stream_error.original_exception from stream_error
+ raise stream_error
+ fallback_error: Final = (
+ stream_error
+ if isinstance(stream_error, MidStreamFallbackError)
+ else _stream_fallback_error_for_raised(stream_error, model, has_generated_content)
+ )
+ if fallback_error is None:
+ raise stream_error
+ await close_superseded()
+ outcome: Final = await self._astream_retry_same_group(
+ fallback_error,
+ initial_kwargs,
+ attempt_function=self._ageneric_api_call_with_fallbacks_anthropic_messages_attempt,
+ )
+ match outcome:
+ case _StreamRetryOpened(response=retried, attempted_retries=attempted, max_retries=budget):
+ async for item in self._aanthropic_messages_yield_recovered(retried, wrapper, (attempted, budget)):
+ yield item
+ case _StreamRetriesExhausted(error=last_error):
+ async for item in self._aanthropic_messages_fallback_attempt(last_error, initial_kwargs, wrapper):
+ yield item
+ case _:
+ assert_never(outcome)
+
+ def _anthropic_messages_group_retry_policy(self, kwargs: Mapping[str, object]) -> dict[str, RetryPolicy] | None:
+ configured: Final = kwargs.get("model_group_retry_policy", self.model_group_retry_policy)
+ return cast("dict[str, RetryPolicy] | None", configured) # cast-ok: same type as the router attribute
+
+ def _anthropic_messages_resolved_retry_policy(self, kwargs: Mapping[str, object]) -> RetryPolicy | None:
+ """
+ The retry policy for this request's model group, unless the request opted out with num_retries=0.
+ A policy that does not resolve to a RetryPolicy governs nothing here, so the stream runs as it would
+ with none; the pre-stream retry loop still reports the malformed policy when an attempt fails.
+ """
+ if kwargs.get("num_retries") == 0:
+ return None
+ model_group: Final = _request_model_group(kwargs)
+ try:
+ return _RESOLVED_RETRY_POLICY_ADAPTER.validate_python(
+ resolve_retry_policy(
+ retry_policy=self.retry_policy,
+ model_group=model_group,
+ model_group_retry_policy=self._anthropic_messages_group_retry_policy(kwargs),
+ )
+ )
+ except (TypeError, ValidationError) as malformed:
+ verbose_router_logger.warning(
+ "The retry policy for %s is not a RetryPolicy, streaming without one: %s", model_group, malformed
+ )
+ return None
+
+ def _anthropic_messages_plain_retry_budget(self, kwargs: Mapping[str, object]) -> int:
+ """
+ The same precedence async_function_with_retries resolves for a failure raised before the
+ stream opened: the request's num_retries, then the routed deployment's, then the router's.
+ """
+ request_num_retries: Final = kwargs.get("num_retries")
+ if isinstance(request_num_retries, int):
+ return request_num_retries
+ deployment_id: Final = routed_deployment_id(kwargs)
+ deployment: Final = self._core_get_deployment(deployment_id) if deployment_id is not None else None
+ deployment_num_retries: Final = _deployment_num_retries(deployment)
+ if deployment_num_retries is not None:
+ return deployment_num_retries
+ return self.num_retries if self.num_retries is not None else 0
+
+ def _anthropic_messages_policy_retries(self, trigger: Exception, kwargs: Mapping[str, object]) -> int | None:
+ policy: Final = self._anthropic_messages_resolved_retry_policy(kwargs)
+ if policy is None:
+ return None
+ return _get_num_retries_from_retry_policy(exception=trigger, retry_policy=policy)
+
+ def _stream_retry_budget(self, trigger: Exception, kwargs: Mapping[str, object]) -> tuple[int, bool]:
+ """
+ The budget an earlier retry of this request committed to, else the retry policy's grant when one
+ names this error, else the plain budget, with whether a policy governs the retry: a committed budget
+ is kept whichever deployment the retry lands on, as async_function_with_retries keeps its own.
+ """
+ policy_retries: Final = self._anthropic_messages_policy_retries(trigger, kwargs)
+ committed_budget: Final = committed_retry_budget_for_request(kwargs)
+ if committed_budget is not None:
+ return committed_budget, policy_retries is not None
+ if policy_retries is None:
+ return self._anthropic_messages_plain_retry_budget(kwargs), False
+ return policy_retries, True
+
+ def _anthropic_messages_stream_can_retry(self, kwargs: Mapping[str, object]) -> bool:
+ """
+ Whether a pre-content failure of this stream would be retried within its own model group,
+ the other case where holding lifecycle frames back from the client buys a clean restart.
+ A retry policy names its budget per error class, so the largest budget it names bounds the
+ hold: holding frames one attempt too long is safe, forwarding them before a retry is not.
+ """
+ attempted: Final = attempted_retries_for_request(kwargs)
+ committed_budget: Final = committed_retry_budget_for_request(kwargs)
+ if committed_budget is not None:
+ return committed_budget > attempted
+ plain_budget: Final = self._anthropic_messages_plain_retry_budget(kwargs)
+ policy: Final = self._anthropic_messages_resolved_retry_policy(kwargs)
+ ceiling: Final = plain_budget if policy is None else max(plain_budget, _retry_policy_ceiling(policy))
+ return ceiling > attempted
+
+ def _anthropic_messages_recoverable_frame_error(
+ self,
+ error_event: tuple[str, str, int] | None,
+ chunk: object,
+ has_generated_content: bool,
+ model_group: str,
+ kwargs: Mapping[str, object],
+ ) -> Exception | None:
+ """
+ The exception a provider `event: error` frame before content recovers through when a retry of its
+ class or a fallback can still take over. A frame nothing can take over for (content already out, a
+ gateway verdict, or a class granted no retry with no fallback) reaches the client as the provider
+ sent it, the way the last exhausted attempt's does.
+ """
+ if has_generated_content or error_event is None:
+ return None
+ error_type, message, status_code = error_event
+ if not _is_retriable_anthropic_status(status_code) or _anthropic_stream_error_is_gateway_verdict(chunk):
+ return None
+ frame_error: Final = anthropic_error_frame_exception(error_type, message, status_code, model_group)
+ budget, _ = self._stream_retry_budget(frame_error, kwargs)
+ if budget > attempted_retries_for_request(kwargs):
+ return frame_error
+ if self._stream_can_fall_back(model_group, kwargs):
+ return frame_error
+ return None
+
+ def _stream_should_retry(
+ self,
+ trigger: Exception,
+ healthy_deployments: list[dict], # mutable-ok: should_retry_this_error's own parameter type
+ all_deployments: list[dict], # mutable-ok: should_retry_this_error's own parameter type
+ kwargs: Mapping[str, object],
+ ) -> bool:
+ try:
+ self.should_retry_this_error(
+ error=trigger,
+ healthy_deployments=healthy_deployments,
+ all_deployments=all_deployments,
+ context_window_fallbacks=_request_fallback_list(
+ kwargs,
+ "context_window_fallbacks",
+ cast( # cast-ok: untyped router attribute
+ "list[object] | None", self._core_context_window_fallbacks
+ ),
+ ),
+ content_policy_fallbacks=_request_fallback_list(
+ kwargs,
+ "content_policy_fallbacks",
+ cast( # cast-ok: untyped router attribute
+ "list[object] | None", self._core_content_policy_fallbacks
+ ),
+ ),
+ regular_fallbacks=_request_fallback_list(
+ kwargs,
+ "fallbacks",
+ cast("list[object] | None", self._core_fallbacks), # cast-ok: untyped router attribute
+ ),
+ )
+ except Exception: # noqa: BLE001 # should_retry_this_error declines by raising the error it was given
+ return False
+ return True
+
+ async def _astream_retry_same_group(
+ self,
+ e: "MidStreamFallbackError",
+ initial_kwargs: Mapping[str, object],
+ attempt_function: "Callable[..., Awaitable[object]]",
+ ) -> _StreamRetryOutcome:
+ """
+ Re-runs the attempt within the request's own model group, the way async_function_with_retries
+ would have for a failure raised before the stream opened, until a retry opens a stream or the
+ budget runs out. Each retry's stream carries its own wrapper with the remaining budget, so a
+ retry that drops before content again continues the same count instead of starting over.
+ `attempt_function` is the request's own attempt (the /v1/messages attempt, or a chat completions one).
+ """
+ model_group: Final = cast(str, initial_kwargs.get("model")) # cast-ok: kwargs always carries the model group
+ retry_kwargs: Final = mid_stream_retry_kwargs(initial_kwargs)
+ handler: Final = retry_kwargs.get("original_generic_function")
+ healthy_deployments, all_deployments = await self._async_get_healthy_deployments(
+ model=model_group,
+ parent_otel_span=get_parent_otel_span_from_kwargs(retry_kwargs),
+ request_kwargs=share_holder_request_kwargs(initial_kwargs, self._ptu_share_teams()),
+ )
+ budget, policy_applies = self._stream_retry_budget(_mid_stream_retry_trigger(e), initial_kwargs)
+ last_error = e # rebind-ok: the newest failure is what the fallback chain and the caller see
+ for attempt in range(attempted_retries_for_request(initial_kwargs), budget):
+ trigger = _mid_stream_retry_trigger(last_error)
+ if not policy_applies and not self._stream_should_retry(
+ trigger, healthy_deployments, all_deployments, initial_kwargs
+ ):
+ return _StreamRetriesExhausted(last_error)
+ if get_metadata_variable_name_from_kwargs(retry_kwargs) in retry_kwargs:
+ self._core_log_retry(kwargs=retry_kwargs, e=trigger)
+ await self._host.sleep(
+ self._time_to_sleep_before_retry(
+ e=trigger,
+ remaining_retries=budget - attempt,
+ num_retries=budget,
+ healthy_deployments=healthy_deployments,
+ all_deployments=all_deployments,
+ )
+ )
+ record_retry_attempt(retry_kwargs, attempted_retries=attempt + 1, max_retries=budget)
+ verbose_router_logger.debug(
+ "Retrying stream that failed before content, attempt %s of %s", attempt + 1, budget
+ )
+ carry_superseded_attempt_usage(retry_kwargs)
+ try:
+ with endpoint_selection(
+ getattr(handler, "__name__", None), getattr(handler, "__module__", None), model=model_group
+ ):
+ response = await attempt_function(**retry_kwargs)
+ except Exception as retry_error: # noqa: BLE001 # the fallback chain judges the failed retry
+ wrapped = _stream_fallback_error_for_raised(retry_error, model_group, False, e.llm_provider)
+ if wrapped is None:
+ return _StreamRetriesExhausted(_stream_pre_content_error(retry_error, model_group, e.llm_provider))
+ last_error = wrapped
+ continue
+ return _StreamRetryOpened(response, attempted_retries=attempt + 1, max_retries=budget)
+ return _StreamRetriesExhausted(last_error)
+
+ async def _aanthropic_messages_yield_recovered(
+ self,
+ recovered: object,
+ wrapper: "FallbackAwareAnthropicMessagesStream",
+ retry_counters: tuple[int, int] | None = None,
+ ) -> AsyncGenerator[bytes, None]:
+ """
+ Hands a retry's or a fallback's response to the client through the wrapper, closing it afterwards.
+ A retry stamps the retry headers async_function_with_retries would have for a pre-stream retry;
+ a later hop the recovered stream makes replaces them with its own, the way a fallback's do.
+ """
+ from litellm.llms.anthropic.messages.native.streaming_iterator import (
+ aclose_if_supported,
+ anthropic_messages_response_as_sse_events,
+ )
+
+ hidden_params, headers = PythonCore._prepare_fallback_hidden_params(recovered)
+ wrapper.merge_fallback_hidden_params(hidden_params, headers)
+ wrapper.adopt_fallback_source(recovered)
+ if retry_counters is not None:
+ add_retry_headers_to_response(
+ response=wrapper, attempted_retries=retry_counters[0], max_retries=retry_counters[1]
+ )
+ try:
+ if hasattr(recovered, "__aiter__"):
+ async for item in cast("AsyncIterator[bytes]", recovered): # cast-ok: __aiter__ checked above
+ wrapper.follow_source_attribution()
+ yield item
+ return
+ # A recovery can resolve to a complete AnthropicMessagesResponse
+ # dict even for a streaming request (e.g. an agentic tool-use
+ # interception loop) - yielding it as-is would put a raw dict
+ # into a byte stream, so it's synthesized into the SSE
+ # lifecycle a real stream would have sent instead.
+ for event in anthropic_messages_response_as_sse_events(
+ cast("AnthropicMessagesResponse", recovered) # cast-ok: non-streaming shape by elimination
+ ):
+ yield event
+ finally:
+ with anyio.CancelScope(shield=True), contextlib.suppress(BaseException):
+ await aclose_if_supported(recovered)
+
+ async def _aanthropic_messages_fallback_attempt(
+ self,
+ e: "MidStreamFallbackError",
+ initial_kwargs: dict[str, Any], # mutable-ok: mutated in-place before re-entering the fallback chain
+ wrapper: "FallbackAwareAnthropicMessagesStream",
+ ) -> AsyncGenerator[bytes, None]:
+ """
+ Re-enters the Router's fallback chain for a mid-stream
+ anthropic_messages error and yields whatever the fallback attempt
+ produces. Split out of _aanthropic_messages_streaming_iterator to
+ keep each function's cyclomatic complexity within the repo's C901
+ budget.
+ """
+ from litellm.exceptions import MidStreamFallbackError
+
+ try:
+ model_group: Final = cast(str, initial_kwargs.get("model")) # cast-ok: model group
+ fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the common_utils list|None param
+ "fallbacks", self._core_fallbacks
+ )
+ context_window_fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the param below
+ "context_window_fallbacks", self._core_context_window_fallbacks
+ )
+ content_policy_fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the param below
+ "content_policy_fallbacks", self._core_content_policy_fallbacks
+ )
+ initial_kwargs["original_function"] = self._ageneric_api_call_with_fallbacks_anthropic_messages_attempt
+ self._update_kwargs_before_fallbacks(
+ model=model_group,
+ kwargs=initial_kwargs,
+ metadata_variable_name="litellm_metadata",
+ )
+ # The content-policy and context-window dispatch branches match on the trigger's own type, so
+ # such an error's MidStreamFallbackError envelope is unwrapped here or the wrong fallback list is consulted.
+ fallback_trigger: Final[Exception] = (
+ e.original_exception
+ if isinstance(
+ e.original_exception,
+ (litellm_exceptions.ContentPolicyViolationError, litellm_exceptions.ContextWindowExceededError),
+ )
+ else e
+ )
+ fallback_response: Final = _OPAQUE_VALUE_ADAPTER.validate_python(
+ await self.async_function_with_fallbacks_common_utils(
+ e=fallback_trigger,
+ disable_fallbacks=fallbacks_disabled_for_request(initial_kwargs),
+ fallbacks=fallbacks,
+ context_window_fallbacks=context_window_fallbacks,
+ content_policy_fallbacks=content_policy_fallbacks,
+ model_group=model_group,
+ args=(),
+ kwargs=initial_kwargs,
+ include_fallback_errors=initial_kwargs.get("include_fallback_errors", False) is True,
+ )
+ )
+ async for fallback_item in self._aanthropic_messages_yield_recovered(fallback_response, wrapper):
+ yield fallback_item
+ except Exception as fallback_error:
+ verbose_router_logger.error("Anthropic messages streaming fallback also failed: %s", fallback_error)
+ if isinstance(fallback_error, MidStreamFallbackError) and fallback_error.original_exception is not None:
+ raise fallback_error.original_exception from fallback_error
+ raise
+
+ async def _aanthropic_messages_with_streaming_fallbacks(
+ self,
+ original_function: Callable,
+ **kwargs: object, # kwargs-ok: forwarded verbatim to original_function, shape varies per call site
+ ) -> Union["AnthropicMessagesResponse", AsyncIterator[bytes]]:
+ """
+ _ageneric_api_call_with_fallbacks for anthropic_messages, with every attempt's stream
+ carrying its own mid-stream fallback handling
+ (see _ageneric_api_call_with_fallbacks_anthropic_messages_attempt). Parity with
+ _aresponses_with_streaming_fallbacks for the Responses API.
+ """
+ return await self._ageneric_api_call_with_fallbacks(
+ original_function=original_function,
+ attempt_function=self._ageneric_api_call_with_fallbacks_anthropic_messages_attempt,
+ **kwargs,
+ )
+
+ async def _ageneric_api_call_with_fallbacks_anthropic_messages_attempt(
+ self,
+ model: str,
+ original_generic_function: Callable[..., Awaitable[object]],
+ **kwargs: object, # kwargs-ok: forwarded verbatim to the per-attempt helper, shape varies per call site
+ ) -> Union["AnthropicMessagesResponse", AsyncIterator[bytes]]:
+ """
+ One attempt of the anthropic_messages fallback chain. A streaming result is wrapped with
+ _aanthropic_messages_streaming_iterator over this attempt's own kwargs, so a fallback hop
+ that fails mid-stream resumes the original group's chain instead of re-raising; the name
+ keeps _get_router_metadata_variable_name resolving to litellm_metadata for every hop.
+ """
+ controls: Final = kwargs.pop(MID_STREAM_FALLBACK_CONTROLS_KEY, None)
+ hop_kwargs: Final = mid_stream_fallback_hop_kwargs(
+ model=model, original_generic_function=original_generic_function, controls=controls, kwargs=kwargs
+ )
+ response: Final = await self._ageneric_api_call_with_fallbacks_helper(
+ model=model, original_generic_function=original_generic_function, **kwargs
+ )
+ carry_over_pre_routing_selection(live_kwargs=kwargs, snapshot=hop_kwargs)
+ carry_over_routed_deployment(live_kwargs=kwargs, snapshot=hop_kwargs)
+ if kwargs.get("stream") and hasattr(response, "__aiter__"):
+ return await self._aanthropic_messages_streaming_iterator(
+ response=cast("AsyncIterator[bytes]", response), # cast-ok: stream=True always returns a byte iterator
+ initial_kwargs=hop_kwargs,
+ )
+ return response
+
+ async def _dispatch_generic_call_type(
+ self,
+ call_type: str,
+ original_function: Callable,
+ **kwargs: object, # kwargs-ok: forwarded verbatim to the per-call-type helper, shape varies per call site
+ ):
+ """
+ factory_function's shared dispatch for call types with no
+ call-specific handling, except anthropic_messages: kept out of
+ factory_function's own async_wrapper (already at the repo's C901
+ complexity ceiling) so routing its mid-stream fallback handling
+ (#24004) doesn't add another branch there.
+ """
+ if call_type == "anthropic_messages":
+ return await self._aanthropic_messages_with_streaming_fallbacks(
+ original_function=original_function, **kwargs
+ )
+ return await self._ageneric_api_call_with_fallbacks(original_function=original_function, **kwargs)
+
+ @staticmethod
+ @contextlib.contextmanager
+ def _video_call_pin(handler_name: str | None, request_kwargs: Mapping[str, object]) -> Generator[None]:
+ """
+ Scopes the deployment a video id names to the routing of one call on that video (status, content, remix,
+ edit, extension). Any other call routes as if no id were sent, whatever its body carries.
+ """
+ video_id: Final = request_kwargs.get("video_id")
+ deployment_id: Final = (
+ decode_video_id_with_provider(video_id).get("deployment_id")
+ if handler_name in VIDEO_ID_CALL_TYPES and isinstance(video_id, str)
+ else None
+ )
+ token: Final = _video_call_deployment_id.set(deployment_id)
+ try:
+ yield
+ finally:
+ _video_call_deployment_id.reset(token)
+
+ @staticmethod
+ @contextlib.contextmanager
+ def _route_capability_pin(handler_name: str | None) -> Generator[None]:
+ token: Final = _routed_call_type.set(handler_name)
+ try:
+ yield
+ finally:
+ _routed_call_type.reset(token)
+
+ @staticmethod
+ def _narrow_to_video_deployment(healthy_deployments: Sequence[_DeploymentT]) -> Sequence[_DeploymentT]:
+ """
+ A video lives on the provider account of the deployment that created it, so a call on an id naming a
+ deployment that every routing filter kept goes to that deployment, else to whichever one routing picks.
+ """
+ deployment_id: Final = _video_call_deployment_id.get()
+ creating: Final = tuple(
+ deployment
+ for deployment in healthy_deployments
+ if deployment_id in PythonCore._deployment_ids((deployment,))
+ )
+ return creating or healthy_deployments
+
+ def _generic_api_call_with_fallbacks(self, model: str, original_function: Callable, **kwargs):
+ """
+ Make a generic LLM API call through the router, this allows you to use retries/fallbacks with litellm router
+ Args:
+ model: The model to use
+ original_function: The handler function to call (e.g., litellm.completion)
+ **kwargs: Additional arguments to pass to the handler function
+ Returns:
+ The response from the handler function
+ """
+ handler_name: Final = original_function.__name__
+ metadata_variable_name: Final = get_router_metadata_variable_name(function_name="generic_api_call")
+ try:
+ verbose_router_logger.debug("Inside _generic_api_call() - handler: %s, model: %s", handler_name, model)
+ self._update_kwargs_before_fallbacks(
+ model=model,
+ kwargs=kwargs,
+ metadata_variable_name=metadata_variable_name,
+ )
+ with self._video_call_pin(handler_name, kwargs), self._route_capability_pin(handler_name):
+ deployment: Final = self.get_available_deployment(
+ model=model,
+ messages=kwargs.get("messages", None),
+ input=kwargs.get("input", None),
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+ self._update_kwargs_with_deployment(
+ deployment=deployment,
+ kwargs=kwargs,
+ function_name="generic_api_call",
+ splits_stream_timeout=handler_name in _STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS,
+ )
+
+ data: Final = deployment["litellm_params"].copy()
+ model_name: Final = data["model"]
+
+ self.total_calls[model_name] += 1
+
+ # For passthrough routes, use the actual model from deployment
+ # and swap model name in endpoint if present
+ if "endpoint" in kwargs and kwargs["endpoint"]:
+ kwargs["endpoint"] = kwargs["endpoint"].replace(model, model_name)
+ kwargs["model"] = model_name
+
+ # Perform pre-call checks for routing strategy
+ self.routing_strategy_pre_call_checks(deployment=deployment)
+
+ custom_llm_provider: Final = provider_for_generic_call(data)
+
+ response: Final = original_function(
+ **{
+ **data,
+ "custom_llm_provider": custom_llm_provider,
+ "caching": self._core_cache_responses,
+ **kwargs,
+ }
+ )
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("%s(model=%s)\x1b[32m 200 OK\x1b[0m", handler_name, model_name)
+ return response
+ except Exception as e:
+ verbose_router_logger.info("%s(model=%s)\x1b[31m Exception %s\x1b[0m", handler_name, model, e)
+ if model is not None:
+ self.fail_calls[model] += 1
+ raise e
+
+ def embedding(
+ self,
+ model: str,
+ input: str | list,
+ is_async: bool | None = False,
+ **kwargs,
+ ) -> EmbeddingResponse:
+ try:
+ kwargs["model"] = model
+ kwargs["input"] = input
+ kwargs["original_function"] = self._embedding
+ self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
+ response: Final = self.function_with_fallbacks(**kwargs)
+ return response
+ except Exception as e:
+ raise e
+
+ def _embedding(self, input: str | list[object], model: str, **kwargs):
+ model_name = None
+ try:
+ verbose_router_logger.debug("Inside embedding()- model: %s", model)
+ with embedding_deployment_selection():
+ deployment: Final = self.get_available_deployment(
+ model=model,
+ input=input,
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+ self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
+ data: Final = deployment["litellm_params"].copy()
+ model_name = data["model"]
+ batch_error: Final = embedding_batch_too_large_error(
+ deployment=_OPAQUE_VALUE_ADAPTER.validate_python(deployment),
+ input=input,
+ request_kwargs=_OPAQUE_VALUE_ADAPTER.validate_python(kwargs),
+ model=model,
+ )
+ logging_obj: Final = _REQUEST_KWARGS_ADAPTER.validate_python(kwargs).get("litellm_logging_obj")
+ if batch_error is not None:
+ if isinstance(logging_obj, LiteLLMLogging):
+ log_rejection_before_provider_call(logging_obj, batch_error)
+ raise batch_error
+ if isinstance(logging_obj, LiteLLMLogging):
+ clear_rejected_before_provider_call(logging_obj)
+
+ potential_model_client: Final = self._get_client(deployment=deployment, kwargs=kwargs, client_type="sync")
+ # check if provided keys == client keys #
+ dynamic_api_key: Final = kwargs.get("api_key", None)
+ if (
+ dynamic_api_key is not None
+ and potential_model_client is not None
+ and dynamic_api_key != potential_model_client.api_key
+ ):
+ model_client = None
+ else:
+ model_client = potential_model_client
+
+ self.total_calls[model_name] += 1
+
+ ### DEPLOYMENT-SPECIFIC PRE-CALL CHECKS ### (e.g. update rpm pre-call. Raise error, if deployment over limit)
+ self.routing_strategy_pre_call_checks(deployment=deployment)
+
+ response: Final = self._host.invoke("embedding")(
+ **{
+ **data,
+ "input": input,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.embedding(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+ return response
+ except Exception as e:
+ verbose_router_logger.info("litellm.embedding(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
+ if model_name is not None:
+ self.fail_calls[model_name] += 1
+ self._stamp_retry_skip_deployment_id(e, kwargs)
+ raise e
+
+ async def aembedding(
+ self,
+ model: str,
+ input: str | list,
+ is_async: bool | None = True,
+ **kwargs,
+ ) -> EmbeddingResponse:
+ try:
+ kwargs["model"] = model
+ kwargs["input"] = input
+ kwargs["original_function"] = self._aembedding
+ self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
+ response: Final = await self.async_function_with_fallbacks(**kwargs)
+ return response
+ except Exception as e:
+ asyncio.create_task(
+ send_llm_exception_alert(
+ ctx=self._core_ctx,
+ request_kwargs=kwargs,
+ error_traceback_str=traceback.format_exc(),
+ original_exception=e,
+ )
+ )
+ raise e
+
+ async def _aembedding(self, input: str | list[object], model: str, **kwargs):
+ model_name = None
+ try:
+ verbose_router_logger.debug("Inside _aembedding()- model: %s", model)
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ with embedding_deployment_selection():
+ deployment: Final = await self._core_async_get_available_deployment(
+ model=model,
+ input=input,
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+ self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
+ data: Final = deployment["litellm_params"].copy()
+ model_name = data["model"]
+ batch_error: Final = embedding_batch_too_large_error(
+ deployment=_OPAQUE_VALUE_ADAPTER.validate_python(deployment),
+ input=input,
+ request_kwargs=_OPAQUE_VALUE_ADAPTER.validate_python(kwargs),
+ model=model,
+ )
+ logging_obj: Final = _REQUEST_KWARGS_ADAPTER.validate_python(kwargs).get("litellm_logging_obj")
+ if batch_error is not None:
+ if isinstance(logging_obj, LiteLLMLogging):
+ await async_log_rejection_before_provider_call(logging_obj, batch_error)
+ raise batch_error
+ if isinstance(logging_obj, LiteLLMLogging):
+ clear_rejected_before_provider_call(logging_obj)
+ model_client: Final = self._get_async_openai_model_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ )
+
+ self.total_calls[model_name] += 1
+ async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
+ response = await self._host.invoke("aembedding")(
+ **{
+ **data,
+ "input": input,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.aembedding(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+ return response
+ except Exception as e:
+ verbose_router_logger.info("litellm.aembedding(model=%s)\x1b[31m Exception %s\x1b[0m", model_name, e)
+ if model_name is not None:
+ self.fail_calls[model_name] += 1
+ self._stamp_retry_skip_deployment_id(e, kwargs)
+ raise e
+
+ #### FILES API ####
+ async def acreate_file(
+ self,
+ model: str,
+ **kwargs,
+ ) -> OpenAIFileObject:
+ try:
+ kwargs["model"] = model
+ kwargs["original_function"] = self._acreate_file
+ self._update_kwargs_before_fallbacks(model=model, kwargs=kwargs)
+ response: Final = await self.async_function_with_fallbacks(**kwargs)
+
+ return response
+ except Exception as e:
+ asyncio.create_task(
+ send_llm_exception_alert(
+ ctx=self._core_ctx,
+ request_kwargs=kwargs,
+ error_traceback_str=traceback.format_exc(),
+ original_exception=e,
+ )
+ )
+ raise e
+
+ async def _acreate_file(
+ self,
+ model: str,
+ **kwargs,
+ ) -> OpenAIFileObject:
+ try:
+ from litellm.litellm_core_utils.routing_common_utils import add_model_file_id_mappings
+
+ verbose_router_logger.debug("Inside _atext_completion()- model: %s", model)
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ healthy_deployments: Final = await self._core_async_get_healthy_deployments(
+ model=model,
+ messages=[{"role": "user", "content": "files-api-fake-text"}],
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ parent_otel_span=parent_otel_span,
+ )
+
+ async def create_file_for_deployment(deployment: dict) -> OpenAIFileObject:
+ from litellm.litellm_core_utils.core_helpers import safe_deep_copy
+
+ kwargs_copy: Final = safe_deep_copy(kwargs)
+ self._update_kwargs_with_deployment(
+ deployment=deployment,
+ kwargs=kwargs_copy,
+ function_name="acreate_file",
+ )
+ data: Final = deployment["litellm_params"].copy()
+ model_name: Final = data["model"]
+
+ model_client: Final = self._get_async_openai_model_client(
+ deployment=deployment,
+ kwargs=kwargs_copy,
+ )
+ self.total_calls[model_name] += 1
+
+ ## REPLACE MODEL IN FILE WITH SELECTED DEPLOYMENT ##
+ # For DB/config deployments, use provider from deployment params
+ custom_llm_provider = data.get("custom_llm_provider")
+ stripped_model, inferred_custom_llm_provider, _, _ = get_llm_provider(
+ model=data["model"],
+ custom_llm_provider=custom_llm_provider,
+ )
+ # Preserve explicitly stored provider, fallback to inferred
+ custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider
+
+ ## REPLACE MODEL IN FILE WITH SELECTED DEPLOYMENT ##
+ purpose: Final = cast(OpenAIFilesPurpose | None, kwargs.get("purpose"))
+ file = cast(FileTypes | None, kwargs.get("file"))
+ if not file or not purpose:
+ raise Exception("file and file_purpose are required for create_file")
+
+ replace_model_in_jsonl_bool: Final = should_replace_model_in_jsonl(
+ purpose=purpose,
+ passthrough=kwargs.get("passthrough") is True,
+ )
+ if replace_model_in_jsonl_bool:
+ file = replace_model_in_jsonl(
+ file_content=file,
+ new_model_name=stripped_model,
+ keep_line_numbers=custom_llm_provider == LlmProviders.VERTEX_AI.value,
+ )
+
+ kwargs_copy["file"] = file
+ if custom_llm_provider == LlmProviders.LITELLM_PROXY.value:
+ kwargs_copy["extra_body"] = MappingProxyType(
+ {
+ **(kwargs_copy.get("extra_body") or MappingProxyType({})),
+ "target_model_names": stripped_model,
+ }
+ )
+ if (
+ "gcs_bucket_name" in data
+ ): # TODO: Remove this once we have a better way to handle GCS bucket name: Problem is that we need to pass the gcs_bucket_name to the router for the create_file call but it doesn't show up there
+ kwargs_copy.setdefault("litellm_metadata", {})["gcs_bucket_name"] = data["gcs_bucket_name"]
+ async with self._deployment_slot(
+ deployment=deployment, kwargs=kwargs_copy, parent_otel_span=parent_otel_span
+ ):
+ response = await self._host.invoke("acreate_file")(
+ **{
+ **data,
+ "custom_llm_provider": custom_llm_provider,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs_copy,
+ }
+ )
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.acreate_file(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+
+ return response
+
+ tasks: Final = []
+
+ if isinstance(healthy_deployments, dict):
+ tasks.append(create_file_for_deployment(healthy_deployments))
+ else:
+ for deployment in healthy_deployments:
+ tasks.append(create_file_for_deployment(deployment))
+
+ responses: Final = await asyncio.gather(*tasks)
+
+ if len(responses) == 0:
+ raise Exception("No healthy deployments found.")
+
+ model_file_id_mapping: Final = add_model_file_id_mappings(
+ healthy_deployments=healthy_deployments, responses=responses
+ )
+ returned_response: Final = cast(OpenAIFileObject, responses[0])
+ returned_response_hidden_params: Final = get_hidden_params(returned_response)
+ if returned_response_hidden_params is not None:
+ returned_response_hidden_params["model_file_id_mapping"] = model_file_id_mapping
+ return returned_response
+ except Exception as e:
+ verbose_router_logger.exception(
+ "litellm.acreate_file(model=%s, %s)\x1b[31m Exception %s\x1b[0m", model, kwargs, e
+ )
+ if model is not None:
+ self.fail_calls[model] += 1
+ raise e
+
+ #### VECTOR STORES API ####
+ async def avector_store_create(
+ self,
+ model: str | None,
+ **kwargs,
+ ):
+ """
+ Create a vector store for a specific model.
+
+ Args:
+ model: Model name from router config
+ **kwargs: Vector store creation parameters
+
+ Returns:
+ VectorStoreCreateResponse
+ """
+ try:
+ # If model is None, use the factory function approach (direct SDK call)
+ if model is None:
+ from litellm.vector_stores.main import acreate
+
+ # Use the factory function to handle the call
+ factory_fn: Final = self.factory_function(acreate, call_type="avector_store_create")
+ return await factory_fn(**kwargs)
+
+ from litellm.vector_stores import acreate as avector_store_create_sdk
+
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ deployment: Final = await self._core_async_get_available_deployment(
+ model=model,
+ messages=[{"role": "user", "content": "vector-store-api-fake-text"}],
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+ data: Final = deployment["litellm_params"].copy()
+ model_name: Final = data["model"]
+ self._update_kwargs_with_deployment(
+ deployment=deployment,
+ kwargs=kwargs,
+ function_name="avector_store_create",
+ )
+
+ model_client: Final = self._get_async_openai_model_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ )
+ self.total_calls[model_name] += 1
+
+ # Get custom provider from deployment params
+ custom_llm_provider = data.get("custom_llm_provider")
+ _, inferred_custom_llm_provider, _, _ = get_llm_provider(
+ model=data["model"],
+ custom_llm_provider=custom_llm_provider,
+ )
+ custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider
+
+ async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
+ response = await avector_store_create_sdk(
+ **{
+ **data,
+ "custom_llm_provider": custom_llm_provider,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.avector_store_create(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+
+ return response
+ except Exception as e:
+ verbose_router_logger.exception(
+ "litellm.avector_store_create(model=%s)\x1b[31m Exception %s\x1b[0m", model, e
+ )
+ if model is not None:
+ self.fail_calls[model] += 1
+ raise e
+
+ def _override_vector_store_methods_for_router(self):
+ """
+ Override factory-generated vector store methods with router-aware implementations.
+ This is called after _initialize_vector_store_endpoints() to ensure our custom
+ methods that handle deployment selection and credential injection are used instead
+ of the generic factory-generated ones.
+ """
+ # Store references to the custom methods defined above
+ # These methods handle proper routing through deployments
+ # The methods are already defined as instance methods above
+
+ async def acreate_batch(
+ self,
+ model: str,
+ **kwargs,
+ ) -> LiteLLMBatch:
+ try:
+ kwargs["model"] = model
+ kwargs["original_function"] = self._acreate_batch
+ metadata_variable_name: Final = get_router_metadata_variable_name(function_name="_acreate_batch")
+ self._update_kwargs_before_fallbacks(
+ model=model,
+ kwargs=kwargs,
+ metadata_variable_name=metadata_variable_name,
+ )
+ response: Final = await self.async_function_with_fallbacks(**kwargs)
+
+ return response
+ except Exception as e:
+ asyncio.create_task(
+ send_llm_exception_alert(
+ ctx=self._core_ctx,
+ request_kwargs=kwargs,
+ error_traceback_str=traceback.format_exc(),
+ original_exception=e,
+ )
+ )
+ raise e
+
+ async def _acreate_batch(
+ self,
+ model: str,
+ **kwargs,
+ ) -> LiteLLMBatch:
+ try:
+ verbose_router_logger.debug("Inside _acreate_batch()- model: %s", model)
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ with batch_create_routing():
+ deployment: Final = await self._core_async_get_available_deployment(
+ model=model,
+ messages=[{"role": "user", "content": "files-api-fake-text"}],
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+
+ data: Final = deployment["litellm_params"].copy()
+ model_name: Final = data["model"]
+ self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name="_acreate_batch")
+
+ model_client: Final = self._get_async_openai_model_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ )
+ self.total_calls[model_name] += 1
+
+ ## SET CUSTOM PROVIDER TO SELECTED DEPLOYMENT ##
+ custom_llm_provider = data.get("custom_llm_provider")
+ _, inferred_custom_llm_provider, _, _ = get_llm_provider(
+ model=data["model"],
+ custom_llm_provider=custom_llm_provider,
+ )
+ custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider
+
+ async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
+ response = await self._host.invoke("acreate_batch")(
+ **{
+ **data,
+ "custom_llm_provider": custom_llm_provider,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.acreate_batch(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+
+ return response
+ except Exception as e:
+ verbose_router_logger.exception(
+ "litellm._acreate_batch(model=%s, %s)\x1b[31m Exception %s\x1b[0m", model, kwargs, e
+ )
+ if model is not None:
+ self.fail_calls[model] += 1
+ self._stamp_retry_skip_deployment_id(e, kwargs)
+ raise e
+
+ async def aretrieve_batch(
+ self,
+ model: str | None = None,
+ **kwargs,
+ ) -> LiteLLMBatch:
+ """
+ Iterate through all models in a model group to check for batch
+
+ Future Improvement - cache the result.
+ """
+ try:
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ requested_model_group: Final = model
+ metadata_variable_name: Final = get_router_metadata_variable_name(function_name="aretrieve_batch")
+ if model is not None:
+ filtered_model_list: (
+ list[DeploymentTypedDict] | list[dict] | dict | None
+ ) = await self._core_async_get_healthy_deployments(
+ model=model,
+ messages=[{"role": "user", "content": "retrieve-api-fake-text"}],
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ parent_otel_span=parent_otel_span,
+ )
+ else:
+ filtered_model_list = self._core_get_model_list()
+ if filtered_model_list is None:
+ raise Exception("Router not yet initialized.")
+
+ receieved_exceptions: Final = []
+
+ async def try_retrieve_batch(model_name: DeploymentTypedDict):
+ try:
+ from litellm.litellm_core_utils.core_helpers import safe_deep_copy
+
+ model: Final = model_name["litellm_params"].get("model")
+ data: Final = model_name["litellm_params"].copy()
+ custom_llm_provider = data.get("custom_llm_provider")
+ if model is None:
+ raise Exception(f"Model not found in litellm_params for deployment: {model_name}")
+ # Update kwargs with the current model name or any other model-specific adjustments
+ ## SET CUSTOM PROVIDER TO SELECTED DEPLOYMENT ##
+ if not custom_llm_provider:
+ _, custom_llm_provider, _, _ = get_llm_provider(model=model)
+ new_kwargs: Final = safe_deep_copy(kwargs)
+ self._update_kwargs_with_deployment(
+ deployment=cast(dict, model_name),
+ kwargs=new_kwargs,
+ function_name="aretrieve_batch",
+ )
+ model_group: Final = requested_model_group or model_name["model_name"]
+ if not new_kwargs[metadata_variable_name].get("model_group"):
+ new_kwargs[metadata_variable_name]["model_group"] = model_group
+ new_kwargs.pop("custom_llm_provider", None)
+ data.pop("custom_llm_provider", None)
+ return await self._host.invoke("aretrieve_batch")(
+ **{
+ **data,
+ "custom_llm_provider": custom_llm_provider,
+ **new_kwargs,
+ },
+ )
+ except Exception as e:
+ import traceback
+
+ traceback.print_exc()
+ receieved_exceptions.append(e)
+ return None
+
+ # Check all models in parallel
+ if (
+ filtered_model_list is not None
+ and isinstance(filtered_model_list, list)
+ and len(filtered_model_list) > 0
+ ):
+ results = await asyncio.gather(
+ *[try_retrieve_batch(cast(DeploymentTypedDict, model)) for model in filtered_model_list],
+ return_exceptions=True,
+ )
+ elif filtered_model_list is not None and isinstance(filtered_model_list, dict):
+ results = await try_retrieve_batch(cast(DeploymentTypedDict, filtered_model_list))
+ else:
+ raise Exception("No healthy deployments found.")
+
+ # Check for successful responses and handle exceptions
+ if results is not None:
+ if isinstance(results, LiteLLMBatch):
+ return results
+ elif isinstance(results, list):
+ for result in results:
+ if isinstance(result, LiteLLMBatch):
+ return result
+
+ # If no valid Batch response was found, raise the first encountered exception
+ if receieved_exceptions:
+ raise receieved_exceptions[0] # Raising the first exception encountered
+
+ # If no exceptions were encountered, raise a generic exception
+ raise Exception(f"Unable to find batch in any model. Received errors - {receieved_exceptions}")
+ except Exception as e:
+ asyncio.create_task(
+ send_llm_exception_alert(
+ ctx=self._core_ctx,
+ request_kwargs=kwargs,
+ error_traceback_str=traceback.format_exc(),
+ original_exception=e,
+ )
+ )
+ raise e
+
+ async def acancel_batch(
+ self,
+ model: str,
+ **kwargs,
+ ) -> LiteLLMBatch:
+ """
+ Cancel a batch through the router with proper model-to-provider mapping.
+ """
+ try:
+ kwargs["model"] = model
+ kwargs["original_function"] = self._acancel_batch
+ metadata_variable_name: Final = get_router_metadata_variable_name(function_name="_acancel_batch")
+ self._update_kwargs_before_fallbacks(
+ model=model,
+ kwargs=kwargs,
+ metadata_variable_name=metadata_variable_name,
+ )
+ response: Final = await self.async_function_with_fallbacks(**kwargs)
+
+ return response
+ except Exception as e:
+ asyncio.create_task(
+ send_llm_exception_alert(
+ ctx=self._core_ctx,
+ request_kwargs=kwargs,
+ error_traceback_str=traceback.format_exc(),
+ original_exception=e,
+ )
+ )
+ raise e
+
+ async def _acancel_batch(
+ self,
+ model: str,
+ **kwargs,
+ ) -> LiteLLMBatch:
+ try:
+ verbose_router_logger.debug("Inside _acancel_batch()- model: %s", model)
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ deployment: Final = await self._core_async_get_available_deployment(
+ model=model,
+ messages=[{"role": "user", "content": "batch-api-fake-text"}],
+ specific_deployment=kwargs.pop("specific_deployment", None),
+ request_kwargs=kwargs,
+ )
+
+ selected_deployment_id: Final = (deployment.get("model_info") or {}).get("id")
+ data: Final = deployment["litellm_params"].copy()
+ resolved_credentials: Final = self._core_get_deployment_credentials_with_provider(
+ model_id=selected_deployment_id or model
+ )
+ if resolved_credentials is not None:
+ data.update(resolved_credentials)
+ data.pop("litellm_credential_name", None)
+ model_name: Final = data["model"]
+ self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name="_acancel_batch")
+
+ model_client: Final = self._get_async_openai_model_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ )
+ self.total_calls[model_name] += 1
+
+ ## SET CUSTOM PROVIDER TO SELECTED DEPLOYMENT ##
+ custom_llm_provider = data.get("custom_llm_provider")
+ _, inferred_custom_llm_provider, _, _ = get_llm_provider(
+ model=data["model"],
+ custom_llm_provider=custom_llm_provider,
+ )
+ custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider
+
+ async with self._deployment_slot(deployment=deployment, kwargs=kwargs, parent_otel_span=parent_otel_span):
+ response = await self._host.invoke("acancel_batch")(
+ **{
+ **data,
+ "custom_llm_provider": custom_llm_provider,
+ "caching": self._core_cache_responses,
+ "client": model_client,
+ **kwargs,
+ }
+ )
+
+ self.success_calls[model_name] += 1
+ verbose_router_logger.info("litellm.acancel_batch(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
+
+ return response
+ except Exception as e:
+ verbose_router_logger.exception(
+ "litellm._acancel_batch(model=%s, %s)\x1b[31m Exception %s\x1b[0m", model, kwargs, e
+ )
+ if model is not None:
+ self.fail_calls[model] += 1
+ self._stamp_retry_skip_deployment_id(e, kwargs)
+ raise e
+
+ async def alist_batches(
+ self,
+ model: str,
+ **kwargs,
+ ):
+ """
+ Return all the batches across all deployments of a model group.
+ """
+
+ filtered_model_list: Final = self._core_get_model_list(model_name=model)
+ if filtered_model_list is None:
+ raise Exception("Router not yet initialized.")
+
+ async def try_retrieve_batch(model: DeploymentTypedDict):
+ try:
+ # Update kwargs with the current model name or any other model-specific adjustments
+ return await self._host.invoke("alist_batches")(**{**model["litellm_params"], **kwargs})
+ except Exception:
+ return None
+
+ # Check all models in parallel
+ results: Final = await asyncio.gather(*[try_retrieve_batch(model) for model in filtered_model_list])
+
+ final_results: Final[dict] = {
+ "object": "list",
+ "data": [],
+ "first_id": None,
+ "last_id": None,
+ "has_more": False,
+ }
+
+ for result in results:
+ if result is not None:
+ ## check batch id
+ if final_results["first_id"] is None and hasattr(result, "first_id"):
+ final_results["first_id"] = getattr(result, "first_id")
+ final_results["last_id"] = getattr(result, "last_id")
+ final_results["data"].extend(result.data)
+
+ ## check 'has_more'
+ if getattr(result, "has_more", False) is True:
+ final_results["has_more"] = True
+
+ return final_results
+
+ #### PASSTHROUGH API ####
+
+ async def _pass_through_moderation_endpoint_factory(
+ self,
+ original_function: Callable,
+ custom_llm_provider: str | None = None,
+ **kwargs,
+ ):
+ # update kwargs with model_group
+ self._update_kwargs_before_fallbacks(
+ model=kwargs.get("model", ""),
+ kwargs=kwargs,
+ )
+ if kwargs.get("model") and self._core_get_model_list(model_name=kwargs["model"]):
+ deployment: Final = await self._core_async_get_available_deployment(
+ model=kwargs["model"],
+ request_kwargs=kwargs,
+ )
+ kwargs["model"] = deployment["litellm_params"]["model"]
+ data: Final = deployment["litellm_params"].copy()
+ self._update_kwargs_with_deployment(
+ deployment=deployment,
+ kwargs=kwargs,
+ )
+ kwargs.update(data)
+
+ return await original_function(**kwargs)
+
+ def factory_function(
+ self,
+ original_function: Callable,
+ call_type: Literal[
+ "assistants",
+ "moderation",
+ "anthropic_messages",
+ "aresponses",
+ "acancel_responses",
+ "acompact_responses",
+ "responses",
+ "aget_responses",
+ "adelete_responses",
+ "afile_delete",
+ "afile_content",
+ "_arealtime",
+ "acreate_realtime_client_secret",
+ "arealtime_calls",
+ "acreate_realtime_transcription_session",
+ "_aresponses_websocket",
+ "acreate_fine_tuning_job",
+ "acancel_fine_tuning_job",
+ "alist_fine_tuning_jobs",
+ "aretrieve_fine_tuning_job",
+ "alist_files",
+ "aimage_edit",
+ "allm_passthrough_route",
+ "alist_input_items",
+ "agenerate_content",
+ "generate_content",
+ "agenerate_content_stream",
+ "generate_content_stream",
+ "avector_store_search",
+ "avector_store_create",
+ "avector_store_retrieve",
+ "avector_store_list",
+ "avector_store_update",
+ "avector_store_delete",
+ "avector_store_file_create",
+ "avector_store_file_list",
+ "avector_store_file_retrieve",
+ "avector_store_file_content",
+ "avector_store_file_update",
+ "avector_store_file_delete",
+ "vector_store_search",
+ "vector_store_create",
+ "vector_store_retrieve",
+ "vector_store_list",
+ "vector_store_update",
+ "vector_store_delete",
+ "vector_store_file_create",
+ "vector_store_file_list",
+ "vector_store_file_retrieve",
+ "vector_store_file_content",
+ "vector_store_file_update",
+ "vector_store_file_delete",
+ "aocr",
+ "ocr",
+ "asearch",
+ "search",
+ "adecisions",
+ "decisions",
+ "asystemone",
+ "systemone",
+ "aextraction",
+ "extraction",
+ "aadapter_generate_content",
+ "avideo_generation",
+ "video_generation",
+ "avideo_list",
+ "video_list",
+ "avideo_status",
+ "video_status",
+ "avideo_content",
+ "video_content",
+ "avideo_remix",
+ "video_remix",
+ "avideo_create_character",
+ "video_create_character",
+ "avideo_get_character",
+ "video_get_character",
+ "avideo_edit",
+ "video_edit",
+ "avideo_extension",
+ "video_extension",
+ "acreate_container",
+ "create_container",
+ "alist_containers",
+ "list_containers",
+ "aretrieve_container",
+ "retrieve_container",
+ "adelete_container",
+ "delete_container",
+ "aupload_container_file",
+ "upload_container_file",
+ "alist_container_files",
+ "list_container_files",
+ "aretrieve_container_file",
+ "retrieve_container_file",
+ "adelete_container_file",
+ "delete_container_file",
+ "acreate_skill",
+ "alist_skills",
+ "aget_skill",
+ "adelete_skill",
+ "acreate_interaction",
+ "create_interaction",
+ "aget_interaction",
+ "get_interaction",
+ "adelete_interaction",
+ "delete_interaction",
+ "acancel_interaction",
+ "cancel_interaction",
+ "acreate_agent",
+ "create_agent",
+ "alist_agents",
+ "list_agents",
+ "aget_agent",
+ "get_agent",
+ "adelete_agent",
+ "delete_agent",
+ "alist_agent_versions",
+ "list_agent_versions",
+ ] = "assistants",
+ ):
+ """
+ Creates appropriate wrapper functions for different API call types.
+
+ Returns:
+ - A synchronous function for synchronous call types
+ - An asynchronous function for asynchronous call types
+ """
+ # Handle synchronous call types
+ if call_type in (
+ "responses",
+ "generate_content",
+ "generate_content_stream",
+ "ocr",
+ "search",
+ "decisions",
+ "systemone",
+ "extraction",
+ "video_generation",
+ "video_list",
+ "video_status",
+ "video_content",
+ "video_remix",
+ "create_container",
+ "list_containers",
+ "retrieve_container",
+ "delete_container",
+ ):
+
+ def sync_wrapper(
+ custom_llm_provider: str | None = None,
+ client: object | None = None,
+ **kwargs,
+ ):
+ return self._generic_api_call_with_fallbacks(original_function=original_function, **kwargs)
+
+ return sync_wrapper
+
+ if call_type in (
+ "vector_store_search",
+ "vector_store_create",
+ "vector_store_retrieve",
+ "vector_store_list",
+ "vector_store_update",
+ "vector_store_delete",
+ ):
+
+ def vector_store_sync_wrapper(
+ custom_llm_provider: str | None = None,
+ client: object | None = None,
+ **kwargs,
+ ):
+ provider_kwargs: Final = (
+ MappingProxyType({**kwargs, "custom_llm_provider": custom_llm_provider})
+ if custom_llm_provider and "custom_llm_provider" not in kwargs
+ else MappingProxyType(kwargs)
+ )
+ search_kwargs: Final = (
+ MappingProxyType(
+ {
+ **provider_kwargs,
+ "_direct_vector_store_embedding_executor": RouterVectorStoreEmbeddingExecutor(
+ router=self._core_public(),
+ metadata=self._vector_store_request_metadata(kwargs),
+ ),
+ }
+ )
+ if call_type == "vector_store_search"
+ else provider_kwargs
+ )
+ if search_kwargs.get("model"):
+ return self._generic_api_call_with_fallbacks(original_function=original_function, **search_kwargs)
+ if call_type == "vector_store_search":
+ return original_function(**MappingProxyType({**search_kwargs, "router": self._core_public()}))
+ return original_function(**search_kwargs)
+
+ return vector_store_sync_wrapper
+
+ if call_type in (
+ "vector_store_file_create",
+ "vector_store_file_list",
+ "vector_store_file_retrieve",
+ "vector_store_file_content",
+ "vector_store_file_update",
+ "vector_store_file_delete",
+ ):
+
+ def vector_store_file_sync_wrapper(
+ custom_llm_provider: str | None = None,
+ client: object | None = None,
+ **kwargs,
+ ):
+ return original_function(
+ custom_llm_provider=custom_llm_provider,
+ client=client,
+ **kwargs,
+ )
+
+ return vector_store_file_sync_wrapper
+
+ if call_type in (
+ "create_agent",
+ "list_agents",
+ "get_agent",
+ "delete_agent",
+ "list_agent_versions",
+ ):
+
+ def managed_agents_sync_wrapper(
+ custom_llm_provider: str | None = None,
+ client: object | None = None,
+ **kwargs,
+ ):
+ if custom_llm_provider and "custom_llm_provider" not in kwargs:
+ kwargs["custom_llm_provider"] = custom_llm_provider
+ if "custom_llm_provider" not in kwargs:
+ kwargs["custom_llm_provider"] = "gemini"
+ return original_function(**kwargs)
+
+ return managed_agents_sync_wrapper
+
+ # Handle asynchronous call types
+ async def async_wrapper(
+ custom_llm_provider: str | None = None,
+ client: AsyncOpenAI | None = None,
+ **kwargs,
+ ):
+ if call_type == "assistants":
+ return await self._pass_through_assistants_endpoint_factory(
+ original_function=original_function,
+ custom_llm_provider=custom_llm_provider,
+ client=client,
+ **kwargs,
+ )
+ elif call_type == "moderation":
+ return await self._pass_through_moderation_endpoint_factory(
+ original_function=original_function, **kwargs
+ )
+ elif call_type in ("asearch", "search"):
+ return await self._asearch_with_fallbacks(
+ original_function=original_function,
+ **kwargs,
+ )
+ elif call_type in (
+ "avector_store_file_create",
+ "avector_store_file_list",
+ "avector_store_file_retrieve",
+ "avector_store_file_content",
+ "avector_store_file_update",
+ "avector_store_file_delete",
+ ):
+ return await self._init_vector_store_api_endpoints(
+ original_function=original_function,
+ custom_llm_provider=custom_llm_provider,
+ **kwargs,
+ )
+ elif call_type == "aresponses":
+ return await self._aresponses_with_streaming_fallbacks(
+ original_function=original_function,
+ **kwargs,
+ )
+ elif call_type in (
+ "acreate_realtime_client_secret",
+ "arealtime_calls",
+ "acreate_realtime_transcription_session",
+ ):
+ return await self._ageneric_api_call_with_fallbacks(
+ original_function=original_function,
+ client=client,
+ **kwargs,
+ )
+ elif call_type in (
+ "anthropic_messages",
+ "_arealtime",
+ "_aresponses_websocket",
+ "acreate_fine_tuning_job",
+ "acancel_fine_tuning_job",
+ "alist_fine_tuning_jobs",
+ "aretrieve_fine_tuning_job",
+ "alist_files",
+ "aimage_edit",
+ "agenerate_content",
+ "agenerate_content_stream",
+ "aocr",
+ "ocr",
+ "adecisions",
+ "asystemone",
+ "aextraction",
+ "avideo_generation",
+ "avideo_list",
+ "avideo_status",
+ "avideo_content",
+ "avideo_remix",
+ "avideo_create_character",
+ "avideo_get_character",
+ "avideo_edit",
+ "avideo_extension",
+ "acreate_skill",
+ "alist_skills",
+ "aget_skill",
+ "adelete_skill",
+ ):
+ return await self._dispatch_generic_call_type(
+ call_type=call_type,
+ original_function=original_function,
+ **kwargs,
+ )
+ elif call_type in (
+ "acreate_container",
+ "alist_containers",
+ "aretrieve_container",
+ "adelete_container",
+ "aupload_container_file",
+ "alist_container_files",
+ "aretrieve_container_file",
+ "adelete_container_file",
+ "aretrieve_container_file_content",
+ ):
+ return await self._init_containers_api_endpoints(
+ original_function=original_function,
+ custom_llm_provider=custom_llm_provider,
+ **kwargs,
+ )
+ elif call_type == "allm_passthrough_route":
+ if client:
+ kwargs["client"] = client
+ return await self._ageneric_api_call_with_fallbacks(
+ original_function=original_function,
+ passthrough_on_no_deployment=True,
+ **kwargs,
+ )
+ elif call_type in (
+ "aget_responses",
+ "acancel_responses",
+ "acompact_responses",
+ "adelete_responses",
+ "alist_input_items",
+ ):
+ return await self._init_responses_api_endpoints(
+ original_function=original_function,
+ **kwargs,
+ )
+ elif call_type in (
+ "avector_store_search",
+ "avector_store_create",
+ "avector_store_retrieve",
+ "avector_store_list",
+ "avector_store_update",
+ "avector_store_delete",
+ ):
+ vector_store_kwargs: Final = (
+ {
+ **kwargs,
+ "_direct_vector_store_embedding_executor": RouterVectorStoreEmbeddingExecutor(
+ router=self._core_public(),
+ metadata=self._vector_store_request_metadata(kwargs),
+ ),
+ }
+ if call_type == "avector_store_search"
+ else kwargs
+ )
+ return await self._init_vector_store_api_endpoints(
+ original_function=original_function,
+ custom_llm_provider=custom_llm_provider,
+ call_type=call_type,
+ **vector_store_kwargs,
+ )
+ elif call_type in ("afile_delete", "afile_content"):
+ return await self._ageneric_api_call_with_fallbacks(
+ original_function=original_function,
+ custom_llm_provider=custom_llm_provider,
+ client=client,
+ **kwargs,
+ )
+ elif call_type in (
+ "acreate_interaction",
+ "create_interaction",
+ "aget_interaction",
+ "adelete_interaction",
+ "acancel_interaction",
+ ):
+ return await self._init_interactions_api_endpoints(
+ original_function=original_function,
+ custom_llm_provider=custom_llm_provider,
+ **kwargs,
+ )
+ elif call_type in (
+ "acreate_agent",
+ "alist_agents",
+ "aget_agent",
+ "adelete_agent",
+ "alist_agent_versions",
+ ):
+ return await self._init_managed_agents_api_endpoints(
+ original_function=original_function,
+ custom_llm_provider=custom_llm_provider,
+ **kwargs,
+ )
+
+ return async_wrapper
+
+ @staticmethod
+ def _vector_store_request_metadata(kwargs: Mapping[str, object]) -> Mapping[str, object]:
+ return vector_store_request_metadata(kwargs)
+
+ async def _init_vector_store_api_endpoints(
+ self,
+ original_function: Callable,
+ custom_llm_provider: str | None = None,
+ call_type: str | None = None,
+ **kwargs,
+ ):
+ """
+ Initialize the Vector Store API endpoints on the router.
+
+ If a model is provided in kwargs, use model-based routing to get
+ the deployment credentials. Otherwise, call the original function directly.
+ """
+ if custom_llm_provider and "custom_llm_provider" not in kwargs:
+ kwargs["custom_llm_provider"] = custom_llm_provider
+
+ # If model is provided, use generic API call with fallbacks for proper routing
+ if kwargs.get("model"):
+ return await self._ageneric_api_call_with_fallbacks(
+ original_function=original_function,
+ **kwargs,
+ )
+
+ # For search, pass the router so provider transforms can resolve
+ # router-managed embedding models (e.g. S3 Vectors query embeddings).
+ # The merge also overrides any client-supplied `router` key.
+ if call_type == "avector_store_search":
+ search_kwargs: Final = MappingProxyType({**kwargs, "router": self._core_public()})
+ return await original_function(**search_kwargs)
+
+ # Otherwise, call the original function directly
+ return await original_function(**kwargs)
+
+ async def _init_containers_api_endpoints(
+ self,
+ original_function: Callable,
+ custom_llm_provider: str | None = None,
+ **kwargs,
+ ):
+ """
+ Initialize the Containers API endpoints on the router.
+
+ LiteLLM-managed container IDs (``cntr_...``) encode ``model_id`` and provider
+ metadata. When present, decode the ID, replace ``container_id`` with the
+ upstream value, and route through ``_ageneric_api_call_with_fallbacks`` so
+ deployment credentials (e.g. regional ``api_base`` for Azure) match
+ :meth:`_init_responses_api_endpoints`. Create/list calls carry no container ID, so
+ they route through the deployment named by ``model`` when the caller passes one,
+ falling back to the direct call when no deployment matches. Otherwise call the
+ handler directly with global provider credentials.
+ """
+ if custom_llm_provider and "custom_llm_provider" not in kwargs:
+ kwargs["custom_llm_provider"] = custom_llm_provider
+
+ from litellm.responses.utils import ResponsesAPIRequestUtils
+
+ container_id: Final = kwargs.get("container_id")
+ _forwarded_model_id: Final = kwargs.get("model_id")
+ if isinstance(container_id, str):
+ decoded: Final = ResponsesAPIRequestUtils.decode_container_id(container_id)
+ original_id: Final = decoded.get("response_id", container_id)
+ if original_id != container_id:
+ kwargs["container_id"] = original_id
+ decoded_provider: Final = decoded.get("custom_llm_provider")
+ if decoded_provider and kwargs.get("custom_llm_provider") == "openai":
+ kwargs["custom_llm_provider"] = decoded_provider
+ # Fall back to the model_id forwarded by the proxy when the container_id
+ # is a native upstream ID (e.g. Azure hex cntr_) that carries no LiteLLM
+ # routing payload, so deployment credentials (api_base, api_key) are applied.
+ model_id: Final = decoded.get("model_id") or (
+ _forwarded_model_id.strip()
+ if isinstance(_forwarded_model_id, str) and _forwarded_model_id.strip()
+ else None
+ )
+ if model_id:
+ kwargs["model"] = model_id
+ return await self._ageneric_api_call_with_fallbacks(
+ original_function=original_function,
+ **kwargs,
+ )
+
+ requested_model: Final = kwargs.get("model")
+ if isinstance(requested_model, str) and requested_model.strip():
+ return await self._ageneric_api_call_with_fallbacks(
+ original_function=original_function,
+ passthrough_on_no_deployment=True,
+ **kwargs,
+ )
+
+ return await original_function(**kwargs)
+
+ async def _init_responses_api_endpoints(
+ self,
+ original_function: Callable,
+ **kwargs,
+ ):
+ """
+ Initialize the Responses API endpoints on the router.
+
+ GET, DELETE, CANCEL Responses API Requests encode the model_id in the response_id, this function decodes the response_id and sets the model to the model_id.
+ """
+ from litellm.responses.utils import ResponsesAPIRequestUtils
+
+ model_id: Final = ResponsesAPIRequestUtils.get_model_id_from_response_id(kwargs.get("response_id"))
+ if model_id is not None:
+ kwargs["model"] = model_id
+ return await self._ageneric_api_call_with_fallbacks(
+ original_function=original_function,
+ **kwargs,
+ )
+
+ async def _init_interactions_api_endpoints(
+ self,
+ original_function: Callable,
+ custom_llm_provider: str | None = None,
+ **kwargs,
+ ):
+ """
+ Initialize the Interactions API endpoints on the router.
+
+ GET, DELETE, CANCEL Interactions API Requests don't need model-based routing,
+ so we call the original function directly with the custom_llm_provider.
+ """
+ if custom_llm_provider and "custom_llm_provider" not in kwargs:
+ kwargs["custom_llm_provider"] = custom_llm_provider
+ # Default to gemini for interactions API
+ if "custom_llm_provider" not in kwargs:
+ kwargs["custom_llm_provider"] = "gemini"
+ # If the proxy accidentally passed agent name as model, clear it
+ if kwargs.get("agent") and kwargs.get("model") == kwargs.get("agent"):
+ kwargs["model"] = None
+ # Model-based interactions use deployment routing + fallbacks; agent-only calls
+ # must not enter model-group lookup (agent name is not a LiteLLM deployment).
+ if kwargs.get("model"):
+ return await self._ageneric_api_call_with_fallbacks(
+ original_function=original_function,
+ **kwargs,
+ )
+ return await original_function(**kwargs)
+
+ async def _init_managed_agents_api_endpoints(
+ self,
+ original_function: Callable,
+ custom_llm_provider: str | None = None,
+ **kwargs,
+ ):
+ """
+ Initialize the Managed Agents API endpoints on the router (v1beta/agents).
+
+ CRUD operations for Gemini managed agents don't need model-based routing,
+ so we call the original function directly with the custom_llm_provider.
+ """
+ if custom_llm_provider and "custom_llm_provider" not in kwargs:
+ kwargs["custom_llm_provider"] = custom_llm_provider
+ if "custom_llm_provider" not in kwargs:
+ kwargs["custom_llm_provider"] = "gemini"
+ return await original_function(**kwargs)
+
+ async def _pass_through_assistants_endpoint_factory(
+ self,
+ original_function: Callable,
+ custom_llm_provider: str | None = None,
+ client: AsyncOpenAI | None = None,
+ **kwargs,
+ ):
+ """Internal helper function to pass through the assistants endpoint"""
+ if custom_llm_provider is None:
+ if self.assistants_config is not None:
+ custom_llm_provider = self.assistants_config["custom_llm_provider"]
+ kwargs.update(self.assistants_config["litellm_params"])
+ else:
+ raise Exception(
+ "'custom_llm_provider' must be set. Either via:\n `Router(assistants_config={'custom_llm_provider': ..})` \nor\n `router.arun_thread(custom_llm_provider=..)`"
+ )
+ return await original_function(custom_llm_provider=custom_llm_provider, client=client, **kwargs)
+
+ #### [END] ASSISTANTS API ####
+
+ async def _maybe_run_weighted_failover(
+ self,
+ exception: Exception,
+ original_model_group: str,
+ all_deployments: Sequence[DeploymentTypedDict],
+ args: tuple,
+ kwargs: dict,
+ input_kwargs: dict,
+ ) -> Any | None:
+ """Same-model-group retry after a failed deployment; returns None if not applicable."""
+ strategy, _ = self._get_routing_context(original_model_group, kwargs)
+ if strategy != "simple-shuffle":
+ return None
+
+ failed_id: Final[str | None] = getattr(exception, "failed_deployment_id", None)
+ if not failed_id:
+ return None
+
+ metadata_variable_name: Final = self._get_metadata_variable_name_from_kwargs(kwargs)
+ meta = kwargs.get(metadata_variable_name)
+ if meta is None:
+ meta = {}
+ kwargs[metadata_variable_name] = meta
+ if not isinstance(meta, dict):
+ return None
+ prev_excluded: Final = set(meta.get("_failover_excluded_ids") or [])
+ excluded: Final = prev_excluded | {failed_id}
+
+ all_ids: Final = {
+ (d.get("model_info") or {}).get("id")
+ for d in all_deployments
+ if (d.get("model_info") or {}).get("id") is not None
+ }
+ # Only consider deployments that are currently healthy (not in cooldown).
+ # Using all_ids here would cause a wasteful run_async_fallback invocation
+ # that fails with RouterRateLimitError whenever the "remaining" entries
+ # are all in cooldown — the inner async_get_healthy_deployments call
+ # would find an empty list and raise immediately.
+ cooldown_ids = set(await async_get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=None))
+ remaining: Final = (all_ids - cooldown_ids) - excluded
+ if not remaining:
+ return None
+
+ verbose_router_logger.debug(
+ "Weighted failover: exclude=%r, remaining=%s for model_group=%r",
+ excluded,
+ len(remaining),
+ original_model_group,
+ )
+
+ meta["_failover_excluded_ids"] = list(excluded)
+
+ entry: Final = {
+ "model": original_model_group,
+ "_excluded_deployment_ids": list(excluded),
+ }
+ # Build a local copy so the weighted-failover keys do not leak back to
+ # the caller's shared kwargs dict (any downstream fallback path reads
+ # the same dict and must not inherit our `_excluded_deployment_ids`
+ # entry).
+ failover_kwargs: Final = {
+ **input_kwargs,
+ "fallback_model_group": [entry],
+ "original_model_group": original_model_group,
+ }
+ try:
+ return await run_async_fallback(*args, **failover_kwargs)
+ except (openai.APIError, RouterRateLimitError, RouterRateLimitErrorBasic):
+ # Expected model-level failure on the retried deployment. All
+ # litellm provider errors derive from openai.APIError; if every
+ # remaining deployment in the group is in cooldown the router
+ # raises RouterRateLimitError (a ValueError, not an APIError).
+ # In either case defer to the regular fallback path. Programming
+ # errors (AttributeError, KeyError, TypeError, etc.) intentionally
+ # propagate so they remain visible.
+ return None
+
+ async def async_function_with_fallbacks_common_utils(
+ self,
+ e: Exception,
+ disable_fallbacks: bool | None,
+ fallbacks: list | None,
+ context_window_fallbacks: list | None,
+ content_policy_fallbacks: list | None,
+ model_group: str | None,
+ args: tuple,
+ kwargs: dict,
+ include_fallback_errors: bool = False,
+ ):
+ """
+ Common utilities for async_function_with_fallbacks
+ """
+ if verbose_router_logger.isEnabledFor(logging.DEBUG):
+ verbose_router_logger.debug("Traceback%s", redact_string(traceback.format_exc()))
+ original_exception: Final = e
+ fallback_model_group = None
+ original_model_group: Final[str | None] = kwargs.get("model")
+ fallback_walk: Final = attempted_fallback_targets_from(kwargs) # pyright: ignore[reportUnknownArgumentType] # keep legacy kwargs untyped
+ # A pre-routing hook (complexity / auto / adaptive / quality routers) picks a tier
+ # behind the router name, and fallbacks are configured per tier, not per router.
+ lookup_groups: Final[tuple[str, ...]] = fallback_lookup_groups(kwargs, model_group)
+ fallback_failure_exception_str = ""
+ no_fallback_group_explained = False
+ hop_depth: Final = kwargs.get("fallback_depth")
+ nested_fallback_hop: Final = isinstance(hop_depth, int) and hop_depth > 0
+
+ unsupported_params_without_fallback: Final = is_bad_request_without_fallback(
+ e, self.fallback_on_unsupported_params
+ )
+ if (
+ disable_fallbacks is True
+ or original_model_group is None
+ or is_guardrail_intervention(e)
+ or unsupported_params_without_fallback
+ ):
+ raise e
+
+ input_kwargs: Final = {
+ "litellm_routing_ctx": self._core_ctx,
+ "original_exception": original_exception,
+ **kwargs,
+ "attempted_targets": fallback_walk,
+ }
+
+ if "max_fallbacks" not in input_kwargs:
+ input_kwargs["max_fallbacks"] = self.max_fallbacks
+ if "fallback_depth" not in input_kwargs:
+ input_kwargs["fallback_depth"] = 0
+ if include_fallback_errors:
+ input_kwargs["include_fallback_errors"] = True
+
+ # ORDER-BASED FALLBACKS: prepend higher order levels to the fallback list
+ # Skip for error types that have their own dedicated fallback handlers
+ _skip_order_fallback: Final = isinstance(
+ e,
+ (litellm_exceptions.ContextWindowExceededError, litellm_exceptions.ContentPolicyViolationError),
+ )
+ _request_team_id: Final[str | None] = (kwargs.get("metadata", {}) or {}).get("user_api_key_team_id")
+ # Use wildcard-aware lookup so order-based fallback also works for model
+ # groups resolved via pattern routing (e.g. `openai/*` -> `openai/gpt-4.1-mini`).
+ order_model_group: Final = get_pre_routing_selection(kwargs) or original_model_group
+ all_deployments: Final = self._core_get_model_list(model_name=order_model_group, team_id=_request_team_id) or ()
+ _order_set: Final[set] = {
+ litellm_utils.get_deployment_order(d)
+ for d in all_deployments
+ if litellm_utils.get_deployment_order(d) is not None
+ }
+ order_values: Final[list] = sorted(_order_set)
+ if len(order_values) > 1 and not _skip_order_fallback:
+ # Determine which order levels have already been tried
+ current_target: Final = kwargs.get("_target_order")
+ skip_up_to: Final = current_target if current_target is not None else order_values[0]
+ # Build order-based fallback entries (skip already-tried levels)
+ order_fallback_entries: Final[list] = [
+ {"model": order_model_group, "_target_order": o} for o in order_values if o > skip_up_to
+ ]
+ # Get external fallbacks — handle both standard and non-standard formats
+ external_fallback_group: list | None = None
+ if fallbacks is not None and lookup_groups:
+ if check_non_standard_fallback_format(fallbacks=fallbacks):
+ # Non-standard formats (e.g. ["claude-3-haiku"] or
+ # [{"model": "...", "messages": [...]}]) are passed through directly
+ external_fallback_group = fallbacks
+ else:
+ external_fallback_group, generic_idx = get_fallback_model_group_for_lookup_groups(
+ fallbacks=fallbacks,
+ lookup_groups=lookup_groups,
+ )
+ if external_fallback_group is None and generic_idx is not None:
+ external_fallback_group = fallbacks[generic_idx]["*"]
+
+ # Combined list: order fallbacks first, then external
+ combined_fallbacks: Final = order_fallback_entries + (external_fallback_group or [])
+
+ if combined_fallbacks:
+ input_kwargs.update(
+ {
+ "fallback_model_group": combined_fallbacks,
+ "original_model_group": original_model_group,
+ }
+ )
+ response = await run_async_fallback(
+ *args,
+ **input_kwargs,
+ )
+ return response
+
+ # Weighted intra-group failover (simple-shuffle only); see _maybe_run_weighted_failover.
+ if self.enable_weighted_failover and not _skip_order_fallback and original_model_group is not None:
+ response = await self._maybe_run_weighted_failover(
+ exception=e,
+ original_model_group=original_model_group,
+ all_deployments=all_deployments,
+ args=args,
+ kwargs=kwargs,
+ input_kwargs=input_kwargs,
+ )
+ if response is not None:
+ return response
+
+ try:
+ verbose_router_logger.info("Trying to fallback b/w models")
+
+ # check if client-side fallbacks are used (e.g. fallbacks = ["gpt-3.5-turbo", "claude-3-haiku"] or fallbacks=[{"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "Hey, how's it going?"}]}]
+ is_non_standard_fallback_format: Final = check_non_standard_fallback_format(fallbacks=fallbacks)
+
+ if is_non_standard_fallback_format:
+ input_kwargs.update(
+ {
+ "fallback_model_group": fallbacks,
+ "original_model_group": original_model_group,
+ }
+ )
+
+ response = await run_async_fallback(
+ *args,
+ **input_kwargs,
+ )
+
+ return response
+
+ if isinstance(e, litellm_exceptions.ContextWindowExceededError):
+ if context_window_fallbacks is not None:
+ context_window_fallback_model_group: Final[list[str] | None] = (
+ self._get_fallback_model_group_for_lookup_groups(
+ fallbacks=context_window_fallbacks,
+ lookup_groups=lookup_groups,
+ )
+ )
+ if context_window_fallback_model_group is None:
+ raise original_exception
+
+ input_kwargs.update(
+ {
+ "fallback_model_group": context_window_fallback_model_group,
+ "original_model_group": original_model_group,
+ }
+ )
+
+ response = await run_async_fallback(
+ *args,
+ **input_kwargs,
+ )
+ return response
+
+ else:
+ error_message = f"model={model_group}. context_window_fallbacks={mask_sensitive_structure(context_window_fallbacks)}. fallbacks={mask_sensitive_structure(fallbacks)}.\n\nSet 'context_window_fallback' - https://docs.litellm.ai/docs/routing#fallbacks"
+ verbose_router_logger.info(
+ msg=f"Got 'ContextWindowExceededError'. No context_window_fallback set. Defaulting \
+ to fallbacks, if available.{error_message}"
+ )
+
+ if self._host.dynamic().expose_router_debug_in_errors:
+ e.message += f"\n{error_message}"
+ elif isinstance(e, litellm_exceptions.ContentPolicyViolationError):
+ if content_policy_fallbacks is not None:
+ content_policy_fallback_model_group: Final[list[str] | None] = (
+ self._get_fallback_model_group_for_lookup_groups(
+ fallbacks=content_policy_fallbacks,
+ lookup_groups=lookup_groups,
+ )
+ )
+ if content_policy_fallback_model_group is None:
+ raise original_exception
+
+ input_kwargs.update(
+ {
+ "fallback_model_group": content_policy_fallback_model_group,
+ "original_model_group": original_model_group,
+ }
+ )
+
+ response = await run_async_fallback(
+ *args,
+ **input_kwargs,
+ )
+ return response
+ else:
+ error_message = format_content_policy_fallback_hint(
+ model_group, content_policy_fallbacks, fallbacks
+ )
+ verbose_router_logger.info(
+ msg=f"Got 'ContentPolicyViolationError'. No content_policy_fallback set. Defaulting \
+ to fallbacks, if available.{error_message}"
+ )
+
+ if self._host.dynamic().expose_router_debug_in_errors:
+ e.message += f"\n{error_message}"
+ if fallbacks is not None and lookup_groups:
+ verbose_router_logger.debug("inside model fallbacks: %s", mask_sensitive_structure(fallbacks))
+ (
+ fallback_model_group,
+ generic_fallback_idx,
+ ) = get_fallback_model_group_for_lookup_groups(
+ fallbacks=fallbacks, # if fallbacks = [{"gpt-3.5-turbo": ["claude-3-haiku"]}]
+ lookup_groups=lookup_groups,
+ )
+ ## if none, check for generic fallback
+ if fallback_model_group is None and generic_fallback_idx is not None:
+ fallback_model_group = fallbacks[generic_fallback_idx]["*"]
+
+ if fallback_model_group is None:
+ masked_fallbacks: Final = mask_sensitive_structure(fallbacks)
+ verbose_router_logger.info(
+ "No fallback model group found for lookup_groups=%s. Fallbacks=%s",
+ " -> ".join(lookup_groups),
+ masked_fallbacks,
+ )
+ if (
+ hasattr(original_exception, "message")
+ and self._host.dynamic().expose_router_debug_in_errors
+ and not nested_fallback_hop
+ ):
+ original_exception.message += format_no_fallback_group_message(lookup_groups, fallbacks)
+ no_fallback_group_explained = True
+ raise original_exception
+
+ input_kwargs.update(
+ {
+ "fallback_model_group": fallback_model_group,
+ "original_model_group": original_model_group,
+ }
+ )
+
+ response = await run_async_fallback(
+ *args,
+ **input_kwargs,
+ )
+
+ return response
+ except Exception as new_exception:
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ fallback_failure_exception_str = truncate_fallback_error_detail(redact_string(str(new_exception)))
+ cooldown_info: Final = await async_get_cooldown_deployments_with_debug_info(
+ ctx=self._core_ctx,
+ parent_otel_span=parent_otel_span,
+ )
+ verbose_router_logger.error(
+ "litellm.router.py::async_function_with_fallbacks() - "
+ "Error occurred while trying to do fallbacks - %s\n"
+ "Debug Information:\nCooldown Deployments=%s",
+ fallback_failure_exception_str,
+ cooldown_info,
+ )
+
+ attempted_fallback_group: Final = input_kwargs.get("fallback_model_group")
+ if (
+ hasattr(original_exception, "message")
+ and self._host.dynamic().expose_router_debug_in_errors
+ and not no_fallback_group_explained
+ and not nested_fallback_hop
+ ):
+ original_exception.message += format_fallback_outcome_message(
+ model_group,
+ attempted_fallback_group,
+ fallback_failure_exception_str,
+ skipped=fallback_walk.skipped,
+ )
+
+ raise original_exception
+
+ @tracer.wrap()
+ async def async_function_with_fallbacks(self, *args, **kwargs):
+ """
+ Try calling the function_with_retries
+ If it fails after num_retries, fall back to another model group
+ """
+ model_group: Final[str | None] = kwargs.get("model")
+ compaction_surface: Final = surface_for_call(
+ getattr(kwargs.get("original_generic_function") or kwargs.get("original_function"), "__name__", "")
+ )
+ if compaction_surface is not None:
+ kwargs["_context_compaction_state"] = initialize_compaction_state(kwargs, compaction_surface)
+ clear_pre_routing_selection(kwargs) # pyright: ignore[reportUnknownArgumentType] # **kwargs is untyped at this boundary
+ if not isinstance(kwargs.get("attempted_targets"), AttemptedFallbackTargets):
+ _fallback_metadata_key: Final = get_router_metadata_variable_name(
+ function_name=getattr(kwargs.get("original_function"), "__name__", None)
+ )
+ _sibling_metadata_key: Final = (
+ "metadata" if _fallback_metadata_key == "litellm_metadata" else "litellm_metadata"
+ )
+ if isinstance(_sibling_metadata := kwargs.get(_sibling_metadata_key), dict):
+ # In place, like every other router bucket write: downstream resolves the bucket by
+ # key presence, so rebinding kwargs to a copy detaches the proxy's request_data write-backs
+ _sibling_metadata.pop("attempted_fallbacks", None)
+ _sibling_metadata.pop("original_model_group", None)
+ if isinstance(_fallback_metadata := kwargs.get(_fallback_metadata_key), dict):
+ _fallback_metadata["attempted_fallbacks"] = 0
+ if model_group is not None:
+ _fallback_metadata["original_model_group"] = model_group
+ include_fallback_errors: Final = kwargs.get("include_fallback_errors", False) is True
+ disable_fallbacks: Final[bool | None] = kwargs.pop("disable_fallbacks", False)
+ record_disable_fallbacks(kwargs, disable_fallbacks is True)
+ fallbacks: Final[list | None] = kwargs.get("fallbacks", self._core_fallbacks)
+ context_window_fallbacks: list | None = kwargs.get(
+ "context_window_fallbacks", self._core_context_window_fallbacks
+ )
+ content_policy_fallbacks: list | None = kwargs.get(
+ "content_policy_fallbacks", self._core_content_policy_fallbacks
+ )
+
+ mock_timeout: Final = kwargs.pop("mock_timeout", None)
+
+ try:
+ self._handle_mock_testing_fallbacks(
+ kwargs=kwargs,
+ model_group=model_group,
+ fallbacks=fallbacks,
+ context_window_fallbacks=context_window_fallbacks,
+ content_policy_fallbacks=content_policy_fallbacks,
+ )
+
+ if mock_timeout is not None:
+ response = await self.async_function_with_retries(*args, **kwargs, mock_timeout=mock_timeout)
+ else:
+ response = await self.async_function_with_retries(*args, **kwargs)
+ if verbose_router_logger.isEnabledFor(logging.DEBUG):
+ verbose_router_logger.debug("Async Response: %s", response)
+ response = add_fallback_headers_to_response(
+ response=response,
+ attempted_fallbacks=0,
+ )
+ return response
+ except Exception as e:
+ return await self.async_function_with_fallbacks_common_utils(
+ e,
+ disable_fallbacks,
+ fallbacks,
+ context_window_fallbacks,
+ content_policy_fallbacks,
+ model_group,
+ args,
+ kwargs,
+ include_fallback_errors=include_fallback_errors,
+ )
+
+ def _handle_mock_testing_fallbacks(
+ self,
+ kwargs: dict,
+ model_group: str | None = None,
+ fallbacks: list | None = None,
+ context_window_fallbacks: list | None = None,
+ content_policy_fallbacks: list | None = None,
+ ):
+ """
+ Helper function to raise a litellm Error for mock testing purposes.
+
+ Raises:
+ litellm.InternalServerError: when `mock_testing_fallbacks=True` passed in request params
+ litellm_exceptions.ContextWindowExceededError: when `mock_testing_context_fallbacks=True` passed in request params
+ litellm.ContentPolicyViolationError: when `mock_testing_content_policy_fallbacks=True` passed in request params
+ """
+ mock_testing_params: Final = MockRouterTestingParams.from_kwargs(kwargs)
+ if (
+ mock_testing_params.mock_testing_fallbacks is not None
+ and mock_testing_params.mock_testing_fallbacks is True
+ ):
+ raise litellm_exceptions.InternalServerError(
+ model=model_group,
+ llm_provider="",
+ message=f"This is a mock exception for model={model_group}, to trigger a fallback. Fallbacks={fallbacks}",
+ )
+ elif (
+ mock_testing_params.mock_testing_context_fallbacks is not None
+ and mock_testing_params.mock_testing_context_fallbacks is True
+ ):
+ raise litellm_exceptions.ContextWindowExceededError(
+ model=model_group,
+ llm_provider="",
+ message=f"This is a mock exception for model={model_group}, to trigger a fallback. \
+ Context_Window_Fallbacks={context_window_fallbacks}",
+ )
+ elif (
+ mock_testing_params.mock_testing_content_policy_fallbacks is not None
+ and mock_testing_params.mock_testing_content_policy_fallbacks is True
+ ):
+ raise litellm_exceptions.ContentPolicyViolationError(
+ model=model_group,
+ llm_provider="",
+ message=f"This is a mock exception for model={model_group}, to trigger a fallback. \
+ Context_Policy_Fallbacks={content_policy_fallbacks}",
+ )
+
+ @staticmethod
+ def _deployment_ids_to_skip_on_retry(exception: Exception, already_skipped: object) -> tuple[str, ...]:
+ failed_deployment_id: Final[str | None] = getattr(exception, "retry_skip_deployment_id", None) or getattr(
+ exception, "failed_deployment_id", None
+ )
+ status_code: Final = getattr(exception, "status_code", None)
+ if not failed_deployment_id or not isinstance(status_code, int):
+ return ()
+ if python_host.invoke("should_retry")(status_code):
+ return ()
+ already_skipped_ids: Final = as_retry_skipped_deployment_ids(already_skipped)
+ skipped: Final = tuple(sorted(frozenset((*already_skipped_ids, failed_deployment_id))))
+ verbose_router_logger.debug(
+ "Retry skips deployments that already answered %s to this request: %s", status_code, skipped
+ )
+ return skipped
+
+ @tracer.wrap()
+ async def async_function_with_retries(self, *args, **kwargs):
+ verbose_router_logger.debug("Inside async function with retries.")
+ original_function: Final = kwargs.pop("original_function")
+ fallbacks: Final = kwargs.pop("fallbacks", self._core_fallbacks)
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+ context_window_fallbacks: Final = kwargs.pop("context_window_fallbacks", self._core_context_window_fallbacks)
+ content_policy_fallbacks: Final = kwargs.pop("content_policy_fallbacks", self._core_content_policy_fallbacks)
+ # Support per-request model_group_retry_policy override (from key/team settings)
+ model_group_retry_policy: Final = kwargs.pop("model_group_retry_policy", self.model_group_retry_policy)
+ model_group: Final[str | None] = kwargs.get("model")
+ request_num_retries: Final[int | None] = kwargs.pop("num_retries", None)
+ num_retries = request_num_retries
+ if num_retries is None:
+ # Fall back to the router setting (then 0) so the comparisons below never
+ # hit `None > int`, which would mask the real upstream error with a TypeError.
+ num_retries = self.num_retries if self.num_retries is not None else 0
+
+ ## ADD MODEL GROUP SIZE TO METADATA - used for model_group_rate_limit_error tracking
+ _metadata: Final[dict] = kwargs.get("litellm_metadata", kwargs.get("metadata")) or {}
+ if "model_group" in _metadata and isinstance(_metadata["model_group"], str):
+ model_list: Final = self._core_get_model_list(model_name=_metadata["model_group"])
+ if model_list is not None:
+ _metadata.update({"model_group_size": len(model_list)})
+
+ verbose_router_logger.debug(
+ "async function w/ retries: original_function - %s, num_retries - %s", original_function, num_retries
+ )
+ ## ADD RETRY TRACKING TO METADATA - used for spend logs retry tracking
+ _metadata["attempted_retries"] = 0
+ _metadata["max_retries"] = num_retries # Updated after overrides in exception handler
+ try:
+ self._handle_mock_testing_rate_limit_error(model_group=model_group, kwargs=kwargs)
+ # if the function call is successful, no exception will be raised and we'll break out of the loop
+ response = await self.make_call(original_function, *args, **kwargs)
+ response = add_retry_headers_to_response(response=response, attempted_retries=0, max_retries=None)
+ return response
+ except Exception as e:
+ if is_guardrail_intervention(e):
+ raise
+ current_attempt = None
+ original_exception = e
+ deployment_num_retries: Final = getattr(e, "num_retries", None)
+
+ if (
+ request_num_retries is None
+ and deployment_num_retries is not None
+ and isinstance(deployment_num_retries, int)
+ ):
+ num_retries = deployment_num_retries
+ """
+ Retry Logic
+ """
+ (
+ _healthy_deployments,
+ _all_deployments,
+ ) = await self._async_get_healthy_deployments(
+ model=kwargs.get("model") or "",
+ parent_otel_span=parent_otel_span,
+ request_kwargs=share_holder_request_kwargs(
+ cast(Mapping[str, object], kwargs), # cast-ok: kwargs keys are param names
+ self._ptu_share_teams(),
+ ),
+ )
+
+ # Check retry policy FIRST, before should_retry_this_error
+ # This allows retry policies to override the healthy deployments check
+ _retry_policy_applies = False
+ if request_num_retries != 0 and (self.retry_policy is not None or model_group_retry_policy is not None):
+ # get num_retries from retry policy
+ # Use the model_group captured at the start of the function, or get it from metadata
+ # kwargs.get("model") at this point is the deployment model, not the model_group
+ _model_group_for_retry_policy = model_group or _metadata.get("model_group") or kwargs.get("model")
+ # Use per-request model_group_retry_policy if provided, otherwise use self
+ _retry_policy_retries: Final = _get_num_retries_from_retry_policy(
+ exception=original_exception,
+ model_group=_model_group_for_retry_policy,
+ model_group_retry_policy=model_group_retry_policy,
+ retry_policy=self.retry_policy,
+ )
+ if _retry_policy_retries is not None:
+ num_retries = _retry_policy_retries
+ _retry_policy_applies = True
+
+ # raises an exception if this error should not be retries
+ # Skip this check if retry policy applies (retry policy takes precedence)
+ if not _retry_policy_applies:
+ self.should_retry_this_error(
+ error=e,
+ healthy_deployments=_healthy_deployments,
+ all_deployments=_all_deployments,
+ context_window_fallbacks=context_window_fallbacks,
+ regular_fallbacks=fallbacks,
+ content_policy_fallbacks=content_policy_fallbacks,
+ )
+ # Update max_retries after overrides (deployment_num_retries / retry_policy)
+ _metadata["max_retries"] = num_retries
+
+ ## LOGGING
+ if num_retries > 0:
+ kwargs = self._core_log_retry(kwargs=kwargs, e=original_exception)
+ first_skipped_ids: Final = self._deployment_ids_to_skip_on_retry(
+ exception=original_exception,
+ already_skipped=kwargs.get("_retry_skipped_deployment_ids"),
+ )
+ if first_skipped_ids:
+ kwargs["_retry_skipped_deployment_ids"] = first_skipped_ids # rebind-ok: the next attempt reads it
+ if isinstance(
+ original_exception, litellm_exceptions.UnsupportedEndpointError
+ ) and _every_deployment_refused(
+ _DEPLOYMENTS_ADAPTER.validate_python(_healthy_deployments), first_skipped_ids
+ ):
+ raise
+ else:
+ raise
+
+ verbose_router_logger.debug("Retrying request with num_retries: %s", num_retries)
+ # decides how long to sleep before retry
+ retry_after: Final = self._time_to_sleep_before_retry(
+ e=original_exception,
+ remaining_retries=num_retries,
+ num_retries=num_retries,
+ healthy_deployments=_healthy_deployments,
+ all_deployments=_all_deployments,
+ )
+
+ await self._host.sleep(retry_after)
+
+ for current_attempt in range(num_retries):
+ try:
+ # Update retry tracking metadata before each retry attempt
+ _metadata["attempted_retries"] = current_attempt + 1
+ _metadata["max_retries"] = num_retries
+ # if the function call is successful, no exception will be raised and we'll break out of the loop
+ response = await self.make_call(original_function, *args, **kwargs)
+ if coroutine_checker.is_async_callable(response): # async errors are often returned as coroutines
+ response = await response
+
+ response = add_retry_headers_to_response(
+ response=response,
+ attempted_retries=current_attempt + 1,
+ max_retries=num_retries,
+ )
+ return response
+
+ except Exception as e:
+ # Always track the latest error so we raise the most
+ # recent exception instead of the first one.
+ original_exception = e
+
+ ## LOGGING
+ kwargs = self._core_log_retry(kwargs=kwargs, e=e)
+ remaining_retries = num_retries - current_attempt - 1
+ _model: str | None = kwargs.get("model")
+ if _model is not None:
+ (
+ _healthy_deployments,
+ _,
+ ) = await self._async_get_healthy_deployments(
+ model=_model,
+ parent_otel_span=parent_otel_span,
+ request_kwargs=share_holder_request_kwargs(
+ cast(Mapping[str, object], kwargs), # cast-ok: kwargs keys are param names
+ self._ptu_share_teams(),
+ ),
+ )
+ else:
+ _healthy_deployments = []
+
+ # Check if this error is non-retryable (e.g., 400 context
+ # window exceeded). If so, raise immediately instead of
+ # continuing the retry loop. Respect retry policy
+ # precedence - only check when no retry policy applies.
+ if not _retry_policy_applies:
+ try:
+ self.should_retry_this_error(
+ error=e,
+ healthy_deployments=_healthy_deployments,
+ all_deployments=_all_deployments,
+ context_window_fallbacks=context_window_fallbacks,
+ regular_fallbacks=fallbacks,
+ content_policy_fallbacks=content_policy_fallbacks,
+ )
+ except Exception:
+ raise e
+
+ skipped_ids = self._deployment_ids_to_skip_on_retry(
+ exception=e,
+ already_skipped=kwargs.get("_retry_skipped_deployment_ids"),
+ )
+ if skipped_ids:
+ kwargs["_retry_skipped_deployment_ids"] = skipped_ids # rebind-ok: the next attempt reads it
+ if isinstance(e, litellm_exceptions.UnsupportedEndpointError) and _every_deployment_refused(
+ _DEPLOYMENTS_ADAPTER.validate_python(_healthy_deployments), skipped_ids
+ ):
+ raise
+ _timeout = self._time_to_sleep_before_retry(
+ e=e,
+ remaining_retries=remaining_retries,
+ num_retries=num_retries,
+ healthy_deployments=_healthy_deployments,
+ all_deployments=_all_deployments,
+ )
+ await self._host.sleep(_timeout)
+
+ if type(original_exception) in self._host.dynamic().exception_types:
+ setattr(original_exception, "max_retries", num_retries)
+ # current_attempt is 0-indexed (0 to num_retries-1), so after loop completion
+ # it represents the last attempt index. The actual number of retries attempted
+ # is current_attempt + 1, which equals num_retries when all retries are exhausted.
+ # We've already verified num_retries > 0 before entering the loop, so current_attempt
+ # will always be set (never None) when we reach this point.
+ actual_retries_attempted: Final = current_attempt + 1 if current_attempt is not None else num_retries
+ setattr(original_exception, "num_retries", actual_retries_attempted)
+
+ raise original_exception
+
+ async def make_call(self, original_function: Any, *args: object, **kwargs: object):
+ """
+ Handler for making a call to the .completion()/.embeddings()/etc. functions.
+ """
+ model_group: Final = kwargs.get("model")
+ generic_function: Final = _OPAQUE_VALUE_ADAPTER.validate_python(kwargs.get("original_generic_function"))
+ endpoint_function: Final = (
+ generic_function if callable(generic_function) else _OPAQUE_VALUE_ADAPTER.validate_python(original_function)
+ )
+ try:
+ with endpoint_selection(
+ getattr(endpoint_function, "__name__", None),
+ getattr(endpoint_function, "__module__", None),
+ model=_OPAQUE_VALUE_ADAPTER.validate_python(model_group),
+ ):
+ response = original_function(*args, **kwargs)
+ if coroutine_checker.is_async_callable(response) or inspect.isawaitable(response):
+ response = await response
+ except Exception:
+ with contextlib.suppress(Exception):
+ await async_io_token_refund_failure(
+ self._core_cache, kwargs, parent_otel_span=get_parent_otel_span_from_kwargs(kwargs)
+ )
+ raise
+ await self.increment_deployment_usage_for_response(response=response, request_kwargs=kwargs)
+ ## PROCESS RESPONSE HEADERS
+ response = await self.set_response_headers(
+ response=response, model_group=model_group if isinstance(model_group, str) else None, request_kwargs=kwargs
+ )
+
+ return response
+
+ def _handle_mock_testing_rate_limit_error(self, kwargs: dict, model_group: str | None = None):
+ """
+ Helper function to raise a mock litellm.RateLimitError error for testing purposes.
+
+ Raises:
+ litellm.RateLimitError error when `mock_testing_rate_limit_error=True` passed in request params
+ """
+ mock_testing_rate_limit_error: Final[bool | None] = kwargs.pop("mock_testing_rate_limit_error", None)
+
+ available_models: Final = self._core_get_model_list(model_name=model_group)
+ num_retries: int | None = None
+
+ if available_models is not None and len(available_models) == 1:
+ num_retries = available_models[0]["litellm_params"].get("num_retries")
+
+ if mock_testing_rate_limit_error is not None and mock_testing_rate_limit_error is True:
+ verbose_router_logger.info(
+ "litellm.router.py::_mock_rate_limit_error() - Raising mock RateLimitError for model=%s", model_group
+ )
+ raise litellm_exceptions.RateLimitError(
+ model=model_group,
+ llm_provider="",
+ message=f"This is a mock exception for model={model_group}, to trigger a rate limit error.",
+ num_retries=num_retries,
+ )
+
+ def should_retry_this_error(
+ self,
+ error: Exception,
+ healthy_deployments: list | None = None,
+ all_deployments: list | None = None,
+ context_window_fallbacks: list | None = None,
+ content_policy_fallbacks: list | None = None,
+ regular_fallbacks: list | None = None,
+ ):
+ """
+ 1. raise an exception for ContextWindowExceededError if context_window_fallbacks is not None
+ 2. raise an exception for ContentPolicyViolationError if content_policy_fallbacks is not None
+
+ 2. raise an exception for RateLimitError if
+ - there are no fallbacks
+ - there are no healthy deployments in the same model group
+ """
+ _num_healthy_deployments = 0
+ if healthy_deployments is not None and isinstance(healthy_deployments, list):
+ _num_healthy_deployments = len(healthy_deployments)
+
+ _num_all_deployments = 0
+ if all_deployments is not None and isinstance(all_deployments, list):
+ _num_all_deployments = len(all_deployments)
+
+ ### CHECK IF RATE LIMIT / CONTEXT WINDOW ERROR / CONTENT POLICY VIOLATION ERROR w/ fallbacks available / Bad Request Error
+ if isinstance(error, litellm_exceptions.ContextWindowExceededError) and context_window_fallbacks is not None:
+ raise error
+
+ if isinstance(error, litellm_exceptions.ContentPolicyViolationError) and content_policy_fallbacks is not None:
+ raise error
+
+ status_code: Final = getattr(error, "status_code", None)
+ if status_code is not None and not self._host.invoke("should_retry")(status_code):
+ # 401/403 are special cases - allow retry if multiple deployments exist (handled below)
+ if status_code not in (401, 403) and not isinstance(error, litellm_exceptions.UnsupportedEndpointError):
+ raise error
+
+ if isinstance(error, litellm_exceptions.NotFoundError):
+ raise error
+ # Error we should only retry if there are other deployments
+ if isinstance(error, openai.RateLimitError):
+ if (
+ _num_healthy_deployments <= 0 # if no healthy deployments
+ and regular_fallbacks is not None # and fallbacks available
+ and len(regular_fallbacks) > 0
+ ):
+ raise error # then raise the error
+
+ if isinstance(error, (openai.AuthenticationError, openai.PermissionDeniedError)):
+ """
+ - if other deployments available -> retry
+ - else -> raise error
+ """
+ if _num_all_deployments <= 1: # if there is only 1 deployment for this model group then don't retry
+ raise error # then raise error
+
+ # Do not retry if there are no healthy deployments
+ # just raise the error
+ if _num_healthy_deployments <= 0: # if no healthy deployments
+ raise error
+
+ return True
+
+ def function_with_fallbacks(self, *args, **kwargs):
+ """
+ Sync wrapper for async_function_with_fallbacks
+
+ Wrapped to reduce code duplication and prevent bugs.
+ """
+ return run_async_function(self.async_function_with_fallbacks, *args, **kwargs)
+
+ def _get_fallback_model_group_for_lookup_groups(
+ self,
+ fallbacks: list[dict[str, list[str]]], # mutable-ok: mirrors the shared resolver's contract
+ lookup_groups: tuple[str, ...],
+ ) -> list[str] | None: # mutable-ok: mirrors the shared resolver's contract
+ fallback_model_group, _ = get_fallback_model_group_for_lookup_groups(
+ fallbacks=fallbacks, lookup_groups=lookup_groups
+ )
+ return fallback_model_group
+
+ def _get_first_default_fallback(self) -> str | None:
+ """
+ Returns the first model from the default_fallbacks list, if it exists.
+ """
+ if self._core_fallbacks is None:
+ return None
+ for fallback in self._core_fallbacks:
+ if isinstance(fallback, dict) and "*" in fallback:
+ default_list = fallback["*"]
+ if isinstance(default_list, list) and len(default_list) > 0:
+ return default_list[0]
+ return None
+
+ def _time_to_sleep_before_retry(
+ self,
+ e: Exception,
+ remaining_retries: int,
+ num_retries: int,
+ healthy_deployments: list | None = None,
+ all_deployments: list | None = None,
+ ) -> int | float:
+ """
+ Calculate back-off, then retry
+
+ It should instantly retry only when:
+ 1. there are healthy deployments in the same model group
+ 2. there are fallbacks for the completion call
+ """
+
+ ## base case - single deployment
+ if all_deployments is not None and len(all_deployments) == 1:
+ pass
+ elif healthy_deployments is not None and isinstance(healthy_deployments, list) and len(healthy_deployments) > 0:
+ return 0
+
+ response_headers: httpx.Headers | None = None
+ if hasattr(e, "response") and hasattr(e.response, "headers"):
+ response_headers = e.response.headers
+ if hasattr(e, "litellm_response_headers"):
+ response_headers = e.litellm_response_headers
+
+ if response_headers is not None:
+ timeout = self._host.invoke("calculate_retry_after")(
+ remaining_retries=remaining_retries,
+ max_retries=num_retries,
+ response_headers=response_headers,
+ min_timeout=self.retry_after,
+ )
+
+ else:
+ timeout = self._host.invoke("calculate_retry_after")(
+ remaining_retries=remaining_retries,
+ max_retries=num_retries,
+ min_timeout=self.retry_after,
+ )
+
+ return timeout
+
+ ### HELPER FUNCTIONS
+
+ async def deployment_callback_on_success(
+ self,
+ kwargs, # kwargs to completion
+ completion_response, # response from completion
+ start_time,
+ end_time, # start/end time
+ ):
+ """
+ Track remaining tpm/rpm quota for model in model_list
+ """
+ try:
+ if kwargs.get("call_type") == "_arealtime":
+ return
+ if is_batch_retrieve_call_type(kwargs.get("call_type")):
+ return
+ standard_logging_object: Final[StandardLoggingPayload | None] = kwargs.get("standard_logging_object", None)
+ if standard_logging_object is None:
+ raise ValueError("standard_logging_object is None")
+ litellm_params: Final = kwargs["litellm_params"]
+ metadata: Final = litellm_params.get("metadata")
+ if metadata is None:
+ return
+ model_group: Final = metadata.get("model_group", None)
+ model_info: Final = litellm_params.get("model_info", {}) or {}
+ deployment_id: Final = model_info.get("id", None)
+ if (
+ model_group is None
+ or deployment_id is None
+ or self._core_get_deployment(model_id=str(deployment_id)) is None
+ ):
+ return
+
+ if self.session_affinity_repin_on_failover:
+ await self._repin_session_after_success(
+ _LITELLM_PARAMS_ADAPTER.validate_python(litellm_params),
+ _OPAQUE_VALUE_ADAPTER.validate_python(completion_response),
+ )
+
+ # Always track deployment successes for cooldown logic, regardless of TPM/RPM limits
+ increment_deployment_successes_for_current_minute(
+ ctx=self._core_ctx,
+ deployment_id=str(deployment_id),
+ )
+
+ total_tokens: Final[float] = standard_logging_object.get("total_tokens", 0)
+ counted_tokens: Final = get_counted_usage_tokens(litellm_params)
+ deployment_name: Final = metadata.get("deployment", None)
+ return await self._increment_deployment_usage(
+ deployment_id=str(deployment_id),
+ deployment_name=deployment_name if isinstance(deployment_name, str) else None,
+ model_group=model_group,
+ total_tokens=total_tokens if counted_tokens is None else max(0, total_tokens - counted_tokens),
+ rpm_increment=1 if counted_tokens is None else 0,
+ parent_otel_span=get_parent_otel_span_from_kwargs(kwargs),
+ )
+
+ except Exception as e:
+ verbose_router_logger.debug(
+ "litellm.router.Router::deployment_callback_on_success(): Exception occured - %s", e
+ )
+
+ async def _repin_session_after_success(self, litellm_params: Mapping[str, object], response: object) -> None:
+ if not self.session_affinity_repin_on_failover:
+ return
+ try:
+ from litellm.router_utils.pre_call_checks.deployment_affinity_check import completed_affinity_response
+
+ if not completed_affinity_response(response):
+ return
+ callback: Final = next(
+ (logger for logger in self._optional_loggers() if isinstance(logger, DeploymentAffinityCheck)),
+ None,
+ )
+ if callback is None:
+ return
+ model_group: Final = callback.get_repin_model_group(litellm_params)
+ if model_group is None or self._is_priority_routing_group(model_group):
+ return
+ registered_ids: Final = self._core_get_candidate_model_ids_for_route(
+ model=model_group, team_id=get_request_team_id(litellm_params)
+ )
+ cooldown_ids: Final = frozenset(
+ model_id
+ for model_id, _ in await self._core_cooldown_cache.async_get_active_cooldowns(
+ model_ids=list(registered_ids), parent_otel_span=None
+ )
+ )
+ await callback.async_repin_session_after_success(litellm_params, model_group, registered_ids - cooldown_ids)
+ except Exception as error: # noqa: BLE001 # Optional pin updates must not interrupt success accounting
+ verbose_router_logger.debug("Session re-pinning failed: %s", error)
+
+ async def increment_deployment_usage_for_response(
+ self,
+ response: object,
+ request_kwargs: dict[str, object],
+ ) -> None:
+ if response is None:
+ return
+ try:
+ deployment_metadata: Final = find_deployment_metadata(request_kwargs)
+ model_group: Final = request_kwargs.get("model")
+ if deployment_metadata is None or not isinstance(model_group, str):
+ return
+ model_info: Final = deployment_metadata["model_info"]
+ deployment_id: Final = model_info.get("id") if isinstance(model_info, dict) else None
+ if deployment_id is None:
+ return
+ total_tokens: Final = response_total_token_count(response)
+ deployment_name: Final = deployment_metadata.get("deployment")
+ deployment_metadata[ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY] = total_tokens
+ try:
+ await self._increment_deployment_usage(
+ deployment_id=str(deployment_id),
+ deployment_name=deployment_name if isinstance(deployment_name, str) else None,
+ model_group=model_group,
+ total_tokens=total_tokens,
+ rpm_increment=1,
+ parent_otel_span=get_parent_otel_span_from_kwargs(request_kwargs),
+ )
+ except Exception:
+ deployment_metadata.pop(ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY, None)
+ raise
+ except Exception as e:
+ verbose_router_logger.debug(
+ "litellm.router.Router::increment_deployment_usage_for_response(): Exception occured - %s", e
+ )
+
+ async def _increment_deployment_usage(
+ self,
+ *,
+ deployment_id: str,
+ deployment_name: str | None,
+ model_group: str,
+ total_tokens: float,
+ rpm_increment: int,
+ parent_otel_span: Span | None,
+ ) -> str | None:
+ from litellm.types.caching import RedisPipelineIncrementOperation
+
+ if total_tokens <= 0 and rpm_increment <= 0:
+ return None
+ if not self._cached_deployment_tracks_usage(deployment_id, model_group):
+ return None
+
+ current_minute: Final = get_utc_datetime().strftime("%H-%M") # use the same timezone regardless of system clock
+ tpm_key: Final = RouterCacheEnum.TPM.value.format(
+ id=deployment_id, current_minute=current_minute, model=deployment_name
+ )
+ rpm_key: Final = RouterCacheEnum.RPM.value.format(
+ id=deployment_id, current_minute=current_minute, model=deployment_name
+ )
+ pipeline_operations: Final[list[RedisPipelineIncrementOperation]] = [
+ RedisPipelineIncrementOperation(key=key, increment_value=increment_value, ttl=RoutingArgs.ttl.value)
+ for key, increment_value in ((tpm_key, total_tokens), (rpm_key, rpm_increment))
+ ]
+ post_increment_values: Final = await self._core_cache.async_increment_cache_pipeline(
+ increment_list=pipeline_operations,
+ parent_otel_span=parent_otel_span,
+ )
+ if post_increment_values is not None and self._core_cache.redis_cache is not None:
+ for operation, value in zip(pipeline_operations, post_increment_values):
+ await self._core_cache.async_set_cache(
+ operation["key"], int(value), local_only=True, ttl=RoutingArgs.ttl.value
+ )
+ return tpm_key
+
+ def _deployment_tracks_usage(self, deployment_id: str, model_group: str) -> bool:
+ deployment_info: Final = self._core_get_deployment(model_id=deployment_id)
+ if deployment_info is None:
+ return False
+ deployment_model_info: Final = self._core_get_router_model_info(
+ deployment=deployment_info,
+ received_model_name=model_group,
+ )
+ configured_limits: Final = (
+ deployment_info.get("tpm", None),
+ deployment_info.get("rpm", None),
+ deployment_info.litellm_params.tpm,
+ deployment_info.litellm_params.rpm,
+ deployment_model_info.get("tpm", None),
+ deployment_model_info.get("rpm", None),
+ )
+ ## Nothing to track only when neither tpm/rpm nor itpm/otpm limits are
+ ## set. IO deployments still record TPM/RPM usage here so TPM-aware
+ ## routing strategies see their real load in mixed model groups; their
+ ## itpm/otpm enforcement runs separately in ModelRateLimitingCheck.
+ return any(limit is not None for limit in configured_limits) or deployment_has_io_token_limits(
+ deployment_info.model_dump()
+ )
+
+ def sync_deployment_callback_on_success(
+ self,
+ kwargs, # kwargs to completion
+ completion_response, # response from completion
+ start_time,
+ end_time, # start/end time
+ ) -> str | None:
+ """
+ Tracks the number of successes for a deployment in the current minute (using in-memory cache)
+
+ Returns:
+ - key: str - The key used to increment the cache
+ - None: if no key is found
+ """
+ if is_batch_retrieve_call_type(kwargs.get("call_type")):
+ return None
+ id = None
+ if kwargs["litellm_params"].get("metadata") is None:
+ pass
+ else:
+ model_group: Final = kwargs["litellm_params"]["metadata"].get("model_group", None)
+ model_info: Final = kwargs["litellm_params"].get("model_info", {}) or {}
+ id = model_info.get("id", None)
+ if model_group is None or id is None:
+ return None
+ elif isinstance(id, int):
+ id = str(id)
+
+ if id is not None:
+ key: Final = increment_deployment_successes_for_current_minute(
+ ctx=self._core_ctx,
+ deployment_id=id,
+ )
+ return key
+
+ return None
+
+ def deployment_callback_on_failure(
+ self,
+ kwargs, # kwargs to completion
+ completion_response, # response from completion
+ start_time,
+ end_time, # start/end time
+ ) -> bool:
+ """
+ 2 jobs:
+ - Tracks the number of failures for a deployment in the current minute (using in-memory cache)
+ - Puts the deployment in cooldown if it exceeds the allowed fails / minute
+
+ Returns:
+ - True if the deployment should be put in cooldown
+ - False if the deployment should not be put in cooldown
+ """
+ verbose_router_logger.debug("Router: Entering 'deployment_callback_on_failure'")
+ try:
+ exception: Final = kwargs.get("exception", None)
+
+ if is_failure_outside_selected_deployment(exception):
+ verbose_router_logger.debug(
+ "Router: Exiting 'deployment_callback_on_failure' without cooldown. "
+ "Failure came from advisor orchestration or deployment selection, not the selected deployment."
+ )
+ return False
+
+ # Cache litellm_params to avoid repeated dict lookups
+ litellm_params: Final = kwargs.get("litellm_params", {})
+ _model_info: Final = litellm_params.get("model_info", {})
+
+ if is_background_response_poll_not_found(exception, litellm_params):
+ verbose_router_logger.debug(
+ "Router: Exiting 'deployment_callback_on_failure' without cooldown. "
+ "Provider 404 came from the background response poll, not the deployment's health."
+ )
+ return False
+
+ if is_video_lookup_not_found(exception, kwargs):
+ verbose_router_logger.debug(
+ "Router: Exiting 'deployment_callback_on_failure' without cooldown. "
+ "Provider 404 says the video is not on this deployment's account, not that the deployment is down."
+ )
+ return False
+
+ exception_status: Final = getattr(exception, "status_code", "")
+
+ if is_caller_timeout_408(kwargs, exception_status):
+ verbose_router_logger.debug(
+ "Router: Exiting 'deployment_callback_on_failure' without cooldown. "
+ "A timeout the caller set caused this 408, not the deployment's health."
+ )
+ return False
+
+ caller_failure_model_info: Final = (
+ _MODEL_INFO_ADAPTER.validate_python(_model_info) if isinstance(_model_info, dict) else None
+ )
+ caller_failure_model_id: Final = (
+ caller_failure_model_info.get("id") if caller_failure_model_info is not None else None
+ )
+ caller_failure_deployment: Final = (
+ self._core_get_deployment(model_id=caller_failure_model_id)
+ if isinstance(caller_failure_model_id, str)
+ else None
+ )
+ if is_caller_scoped_auth_failure(caller_failure_deployment, exception_status):
+ verbose_router_logger.debug(
+ "Router: Exiting 'deployment_callback_on_failure' without cooldown. "
+ "Caller-scoped OAuth authentication failed, not the deployment."
+ )
+ return False
+
+ exception_headers: Final = exception_mapping_utils._get_response_headers(original_exception=exception)
+
+ # Determine cooldown time with priority: deployment config > response header > router default
+ deployment_cooldown: Final = _first_present(
+ _model_info if isinstance(_model_info, dict) else None, litellm_params, key="cooldown_time"
+ )
+
+ header_cooldown = None
+ if exception_headers is not None:
+ header_cooldown = litellm_utils._get_retry_after_from_exception_header(
+ response_headers=exception_headers
+ )
+ ##############################################
+ # Logic to determine cooldown time
+ # 1. Check if a cooldown time is set in the deployment config
+ # 2. Check if a cooldown time is set in the response header
+ # 3. If no cooldown time is set, use the router default cooldown time
+ ##############################################
+ if deployment_cooldown is not None and deployment_cooldown >= 0:
+ _time_to_cooldown = deployment_cooldown
+ elif header_cooldown is not None and header_cooldown >= 0:
+ _time_to_cooldown = header_cooldown
+ else:
+ _time_to_cooldown = self._core_cooldown_time
+
+ if isinstance(_model_info, dict):
+ deployment_id: Final[str | None] = _model_info.get("id")
+ if deployment_id is None:
+ return False
+ increment_deployment_failures_for_current_minute(
+ ctx=self._core_ctx,
+ deployment_id=deployment_id,
+ )
+ result: Final = set_cooldown_deployments(
+ ctx=self._core_ctx,
+ exception_status=exception_status,
+ original_exception=exception,
+ deployment=deployment_id,
+ time_to_cooldown=_time_to_cooldown,
+ requested_model_group=(
+ get_litellm_metadata_from_kwargs(
+ cast("dict[str, object]", kwargs) # cast-ok: untyped kwargs dict
+ )
+ or {}
+ ).get("model_group"),
+ request_kwargs=cast("dict[str, object]", kwargs), # cast-ok: kwargs is the untyped request dict
+ ) # setting deployment_id in cooldown deployments
+
+ return result
+ else:
+ verbose_router_logger.debug(
+ "Router: Exiting 'deployment_callback_on_failure' without cooldown. No model_info found."
+ )
+ return False
+
+ except Exception as e:
+ raise e
+
+ async def async_deployment_callback_on_failure(
+ self, kwargs, completion_response: object | None, start_time, end_time
+ ):
+ """
+ Update RPM usage for a deployment
+ """
+ if is_batch_retrieve_call_type(kwargs.get("call_type")):
+ return
+ deployment_name: Final = kwargs["litellm_params"]["metadata"].get(
+ "deployment", None
+ ) # handles wildcard routes - by giving the original name sent to `litellm.completion`
+ model_group: Final = kwargs["litellm_params"]["metadata"].get("model_group", None)
+ model_info: Final = kwargs["litellm_params"].get("model_info", {}) or {}
+ id = model_info.get("id", None)
+ if model_group is None or id is None:
+ return
+ elif isinstance(id, int):
+ id = str(id)
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(kwargs)
+
+ dt: Final = get_utc_datetime()
+ current_minute: Final = dt.strftime("%H-%M") # use the same timezone regardless of system clock
+
+ ## RPM
+ rpm_key: Final = RouterCacheEnum.RPM.value.format(id=id, current_minute=current_minute, model=deployment_name)
+ with service_target(ROUTER_USAGE_TARGET):
+ await self._core_cache.async_increment_cache(
+ key=rpm_key,
+ value=1,
+ parent_otel_span=parent_otel_span,
+ ttl=RoutingArgs.ttl.value,
+ )
+
+ def _get_metadata_variable_name_from_kwargs(self, kwargs: dict) -> Literal["metadata", "litellm_metadata"]:
+ """
+ Helper to return what the "metadata" field should be called in the request data
+
+ - New endpoints return `litellm_metadata`
+ - Old endpoints return `metadata`
+
+ Context:
+ - LiteLLM used `metadata` as an internal field for storing metadata
+ - OpenAI then started using this field for their metadata
+ - LiteLLM is now moving to using `litellm_metadata` for our metadata
+ """
+ return get_metadata_variable_name_from_kwargs(kwargs)
+
+ def _core_log_retry(self, kwargs: dict, e: Exception) -> dict:
+ """
+ When a retry or fallback happens, record which model group, deployment and attempt just failed and why,
+ and count it toward the request-wide num_retries_per_request cap
+ """
+ from litellm.litellm_core_utils.get_litellm_params import get_base_model_from_litellm_call_metadata
+ from litellm.types.router import RetryAttemptRecord
+
+ _metadata_var: Final = "litellm_metadata" if "litellm_metadata" in kwargs else "metadata"
+ request_metadata: Final = cast("Mapping[str, object]", kwargs[_metadata_var]) # cast-ok: untyped kwargs
+ model_group: Final = kwargs.get("model")
+ model_info: Final = request_metadata.get("model_info")
+ deployment_id: Final = model_info.get("id") if isinstance(model_info, Mapping) else None
+ base_model: Final = get_base_model_from_litellm_call_metadata(
+ metadata=request_metadata
+ ) or self._deployment_params_base_model(deployment_id if isinstance(deployment_id, str) else None)
+ deployment_model: Final = request_metadata.get("deployment")
+ attempted_retries: Final = request_metadata.get("attempted_retries")
+ attempt_record: Final[RetryAttemptRecord] = {
+ "model_group": model_group if isinstance(model_group, str) else None,
+ "deployment_id": deployment_id if isinstance(deployment_id, str) else None,
+ "model": deployment_model if isinstance(deployment_model, str) else None,
+ "base_model": base_model if isinstance(base_model, str) else None,
+ "exception_type": type(e).__name__,
+ "exception_string": str(e),
+ "attempted_retries": attempted_retries if type(attempted_retries) is int else None,
+ }
+ earlier_breadcrumbs: Final = request_metadata.get("previous_models")
+ kept_breadcrumbs: Final[tuple[object, ...]] = (
+ tuple(earlier_breadcrumbs)[-(RETRY_BREADCRUMB_LIMIT - 1) :]
+ if isinstance(earlier_breadcrumbs, (list, tuple))
+ else ()
+ )
+ breadcrumbs: Final = (*kept_breadcrumbs, attempt_record)
+ earlier: Final = request_metadata.get("request_retry_count")
+ request_retry_count: Final = (earlier if type(earlier) is int and 0 <= earlier else 0) + 1
+ decision: Final = request_metadata.get("routing_decision")
+ router_name: Final = decision.get("router_model_name") if isinstance(decision, Mapping) else None
+ if isinstance(router_name, str):
+ phase_event(
+ "litellm.routing.retry",
+ {
+ "litellm.routing.router_model_name": router_name,
+ "litellm.retry.count": request_retry_count,
+ "error.type": type(e).__name__,
+ **({"litellm.deployment.model_group": model_group} if isinstance(model_group, str) else {}),
+ **({"litellm.deployment.id": deployment_id} if isinstance(deployment_id, str) else {}),
+ },
+ )
+ kwargs[_metadata_var]["previous_models"] = breadcrumbs # rebind-ok: the logging object already holds this dict
+ kwargs[_metadata_var]["request_retry_count"] = request_retry_count # rebind-ok: same dict, read by the cap
+ return kwargs
+
+ def _deployment_params_base_model(self, deployment_id: str | None) -> str | None:
+ """The ``base_model`` a deployment declares under ``litellm_params`` (the Azure named-deployment
+ shape), the same key the success path's ``get_base_model_from_metadata`` reads."""
+ deployment: Final = self._core_get_deployment(model_id=deployment_id) if deployment_id is not None else None
+ if deployment is None:
+ return None
+ extra_params: Final = cast( # cast-ok: pydantic extras are untyped, base_model is isinstance-checked below
+ "Mapping[str, object]", deployment.litellm_params.model_extra or {}
+ )
+ base_model: Final = extra_params.get("base_model")
+ return base_model if isinstance(base_model, str) else None
+
+ def _update_usage(self, deployment_id: str, parent_otel_span: Span | None) -> int:
+ """
+ Update deployment rpm for that minute
+
+ Returns:
+ - int: request count
+ """
+ rpm_key: Final = deployment_id
+
+ request_count = self._core_cache.get_cache(key=rpm_key, parent_otel_span=parent_otel_span, local_only=True)
+ if request_count is None:
+ request_count = 1
+ self._core_cache.set_cache(key=rpm_key, value=request_count, local_only=True, ttl=60) # only store for 60s
+ else:
+ request_count += 1
+ self._core_cache.set_cache(key=rpm_key, value=request_count, local_only=True) # don't change existing ttl
+
+ return request_count
+
+ def _has_default_fallbacks(self) -> bool:
+ if self._core_fallbacks is None:
+ return False
+ for fallback in self._core_fallbacks:
+ if isinstance(fallback, dict):
+ if "*" in fallback:
+ return True
+ return False
+
+ def _has_content_policy_fallback(self, model_group: str, kwargs: Mapping[str, Any]) -> bool:
+ """
+ Whether a content-policy fallback would resolve for this request, keyed the same way
+ async_function_with_fallbacks_common_utils resolves it: the tier a pre-routing hook
+ selected wins over the requested group. Raising without this returning True would turn
+ a deliverable response into an error the fallback chain cannot recover from.
+ """
+ content_policy_fallbacks: Final = kwargs.get("content_policy_fallbacks", self._core_content_policy_fallbacks)
+ if content_policy_fallbacks is not None:
+ return has_unattempted_fallback_target(
+ self._get_fallback_model_group_for_lookup_groups(
+ fallbacks=content_policy_fallbacks,
+ lookup_groups=fallback_lookup_groups(kwargs, model_group),
+ ),
+ kwargs,
+ )
+ if self._has_default_fallbacks():
+ return True
+ verbose_router_logger.debug(
+ "No content-policy fallback available. Returning original response. model=%s, content_policy_fallbacks=%s",
+ model_group,
+ content_policy_fallbacks,
+ )
+ return False
+
+ def _refusal_fallback_available(self, model_group: str, kwargs: Mapping[str, Any]) -> bool:
+ """
+ Whether a safeguard refusal can actually be recovered by the dispatcher. A configured
+ content-policy list is authoritative; with none configured at all, the dispatcher falls
+ through to the generic fallbacks lookup, so the gate mirrors that reachability and arms
+ on a resolving generic chain (tier first, then the requested group, then "*").
+ """
+ if fallbacks_disabled_for_request(kwargs):
+ return False
+ content_policy_fallbacks: Final = kwargs.get("content_policy_fallbacks", self._core_content_policy_fallbacks)
+ if content_policy_fallbacks is not None:
+ return self._has_content_policy_fallback(model_group, kwargs)
+ if self._has_default_fallbacks():
+ return True
+ fallbacks: Final = kwargs.get("fallbacks", self._core_fallbacks)
+ if fallbacks is None:
+ return False
+ resolved, _ = get_fallback_model_group_for_lookup_groups(
+ fallbacks=fallbacks,
+ lookup_groups=fallback_lookup_groups(kwargs, model_group),
+ )
+ return has_unattempted_fallback_target(resolved, kwargs)
+
+ def _stream_order_levels(self, model_group: str, kwargs: Mapping[str, Any]) -> tuple[int, ...]:
+ """
+ The distinct deployment order levels the fallback dispatcher would see for this request,
+ computed the same way: the tier a pre-routing hook selected wins over the requested group.
+ """
+ request_team_id: Final[str | None] = (kwargs.get("metadata", {}) or {}).get("user_api_key_team_id")
+ order_model_group: Final = get_pre_routing_selection(kwargs) or model_group
+ all_deployments: Final = self._core_get_model_list(model_name=order_model_group, team_id=request_team_id) or ()
+ return tuple(
+ sorted(
+ {
+ litellm_utils.get_deployment_order(d)
+ for d in all_deployments
+ if litellm_utils.get_deployment_order(d) is not None
+ }
+ )
+ )
+
+ def _stream_can_fall_back(self, model_group: str, kwargs: Mapping[str, Any]) -> bool:
+ """
+ Whether async_function_with_fallbacks_common_utils could still route a
+ MidStreamFallbackError somewhere for this request (order levels, weighted
+ failover, content-policy or generic fallbacks), which is the only case where
+ holding lifecycle frames back from the client buys a clean retry. Errs toward
+ True whenever a dispatcher path might reach a fallback.
+ """
+ if fallbacks_disabled_for_request(kwargs):
+ return False
+ if self.enable_weighted_failover:
+ return True
+ order_levels: Final = self._stream_order_levels(model_group, kwargs)
+ if len(order_levels) > 1:
+ current_target: Final = kwargs.get("_target_order")
+ skip_up_to: Final = current_target if current_target is not None else order_levels[0]
+ if any(o > skip_up_to for o in order_levels):
+ return True
+ content_policy_fallbacks: Final = kwargs.get("content_policy_fallbacks", self._core_content_policy_fallbacks)
+ if content_policy_fallbacks is not None and self._has_content_policy_fallback(model_group, kwargs):
+ return True
+ fallbacks: Final = kwargs.get("fallbacks", self._core_fallbacks)
+ if not fallbacks:
+ return False
+ if check_non_standard_fallback_format(fallbacks=fallbacks):
+ return True
+ resolved, _ = get_fallback_model_group_for_lookup_groups(
+ fallbacks=fallbacks,
+ lookup_groups=fallback_lookup_groups(kwargs, model_group),
+ )
+ return has_unattempted_fallback_target(resolved, kwargs)
+
+ def _should_raise_content_policy_error(self, model: str, response: ModelResponse, kwargs: dict) -> bool:
+ """
+ Determines if a content policy error should be raised.
+
+ Only raised if a fallback is available.
+
+ Else, original response is returned.
+ """
+ if response.choices and len(response.choices) > 0:
+ if response.choices[0].finish_reason != "content_filter":
+ return False
+
+ return self._has_content_policy_fallback(model, kwargs)
+
+ def _should_raise_anthropic_refusal_error(
+ self, model: str, original_generic_function: Callable, response: object, kwargs: Mapping[str, object]
+ ) -> bool:
+ """
+ The /v1/messages twin of _should_raise_content_policy_error: an Anthropic safeguard
+ refusal (stop_reason "refusal" carrying stop_details) re-enters the fallback chain only
+ when a content-policy fallback is configured; a plain refusal without stop_details, or
+ any response with nothing configured, is returned to the client unchanged.
+ """
+ from litellm.llms.anthropic.messages.native.utils import (
+ get_safeguard_refusal_stop_details,
+ )
+
+ if getattr(original_generic_function, "__name__", "") != "anthropic_messages":
+ return False
+ if get_safeguard_refusal_stop_details(response) is None:
+ return False
+ return self._refusal_fallback_available(model, kwargs)
+
+ def _get_healthy_deployments(self, model: str, parent_otel_span: Span | None):
+ _all_deployments: list = []
+ try:
+ _, _all_deployments = self._common_checks_available_deployment(
+ model=model,
+ )
+ if isinstance(_all_deployments, dict):
+ return []
+ except Exception:
+ pass
+
+ unhealthy_deployments: Final = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
+ unhealthy_set: Final = set(unhealthy_deployments)
+ healthy_deployments: list = [d for d in _all_deployments if d["model_info"]["id"] not in unhealthy_set]
+ healthy_deployments = self._filter_blocked_deployments(healthy_deployments)
+
+ return healthy_deployments, _all_deployments
+
+ async def _async_get_healthy_deployments(
+ self, model: str, parent_otel_span: Span | None, request_kwargs: Mapping[str, object] | None = None
+ ) -> tuple[list[dict], list[dict]]:
+ """
+ Returns Tuple of:
+ - Tuple[List[Dict], List[Dict]]:
+ 1. healthy_deployments: list of healthy deployments the caller of ``request_kwargs`` may use
+ 2. all_deployments: list of all deployments that caller may use
+ """
+ _all_deployments: list = []
+ try:
+ _, _all_deployments = self._common_checks_available_deployment(
+ model=model,
+ request_kwargs=request_kwargs,
+ )
+ if isinstance(_all_deployments, dict):
+ return [], _all_deployments
+ except Exception:
+ pass
+
+ unhealthy_deployments: Final = await async_get_cooldown_deployments(
+ ctx=self._core_ctx, parent_otel_span=parent_otel_span
+ )
+ # Convert to set for O(1) lookup instead of O(n)
+ unhealthy_deployments_set: Final = set(unhealthy_deployments)
+ healthy_deployments: list = [
+ d for d in _all_deployments if d["model_info"]["id"] not in unhealthy_deployments_set
+ ]
+ healthy_deployments = self._filter_blocked_deployments(healthy_deployments)
+ return healthy_deployments, _all_deployments
+
+ def routing_strategy_pre_call_checks(self, deployment: dict):
+ """
+ Mimics 'async_routing_strategy_pre_call_checks'
+
+ Ensures consistent update rpm implementation for 'usage-based-routing-v2'
+
+ Returns:
+ - None
+
+ Raises:
+ - Rate Limit Exception - If the deployment is over it's tpm/rpm limits
+ """
+ for _callback in self._routing_callbacks():
+ if isinstance(_callback, CustomLogger):
+ _callback.pre_call_check(deployment)
+
+ async def async_routing_strategy_pre_call_checks(
+ self,
+ deployment: dict,
+ parent_otel_span: Span | None,
+ logging_obj: LiteLLMLogging | None = None,
+ ):
+ """
+ For usage-based-routing-v2, enables running rpm checks before the call is made, inside the semaphore.
+
+ -> makes the calls concurrency-safe, when rpm limits are set for a deployment
+
+ Returns:
+ - None
+
+ Raises:
+ - Rate Limit Exception - If the deployment is over it's tpm/rpm limits
+ """
+ for _callback in self._routing_callbacks():
+ if isinstance(_callback, CustomLogger):
+ try:
+ await _callback.async_pre_call_check(deployment, parent_otel_span)
+ except litellm_exceptions.RateLimitError as e:
+ ## LOG FAILURE EVENT
+ if logging_obj is not None:
+ asyncio.create_task(
+ logging_obj.dispatch_failure_handlers(
+ exception=e,
+ traceback_exception=traceback.format_exc(),
+ prefer_async_handlers=True,
+ )
+ )
+ set_cooldown_deployments(
+ ctx=self._core_ctx,
+ exception_status=e.status_code,
+ original_exception=e,
+ deployment=deployment["model_info"]["id"],
+ time_to_cooldown=self._core_cooldown_time,
+ )
+ raise e
+ except Exception as e:
+ ## LOG FAILURE EVENT
+ if logging_obj is not None:
+ asyncio.create_task(
+ logging_obj.dispatch_failure_handlers(
+ exception=e,
+ traceback_exception=traceback.format_exc(),
+ prefer_async_handlers=True,
+ )
+ )
+ raise e
+
+ def _deployment_as_configured_now(self, deployment: Mapping[str, object]) -> Mapping[str, object]:
+ model_info: Final = deployment.get("model_info")
+ model_id: Final = (
+ _MODEL_INFO_ADAPTER.validate_python(model_info).get("id") if isinstance(model_info, dict) else None
+ )
+ current: Final = self._core_get_deployment(model_id) if isinstance(model_id, str) else None
+ return deployment if current is None else current.model_dump(exclude_none=True)
+
+ async def async_admit_deployment_turn(
+ self,
+ model_group: str,
+ opened_on: Mapping[str, object],
+ request_kwargs: Mapping[str, object],
+ ) -> Mapping[str, object]:
+ deployment: Final = self._deployment_as_configured_now(opened_on)
+ rate_limit_check: Final = next(
+ (callback for callback in self._routing_callbacks() if isinstance(callback, ModelRateLimitingCheck)), None
+ )
+ io_token_admission: Final[Mapping[str, object]] = (
+ MappingProxyType({})
+ if rate_limit_check is None
+ else await async_admit_io_token_turn(rate_limit_check.dual_cache, deployment, get_utc_datetime())
+ )
+ budget_limiter: Final = self._core_get_router_deployment_budget_limiter()
+ if budget_limiter is not None:
+ try:
+ await budget_limiter.async_filter_deployments( # pyright: ignore[reportUnknownMemberType] # legacy signature takes untyped deployment dicts
+ model=model_group,
+ healthy_deployments=[dict(deployment)],
+ messages=None,
+ request_kwargs=dict(request_kwargs),
+ )
+ except ValueError as e:
+ raise litellm_exceptions.RateLimitError(
+ message=str(e),
+ llm_provider="",
+ model=model_group,
+ category=litellm_exceptions.RateLimitErrorCategory.LITELLM_RATE_LIMIT,
+ ) from e
+ await self._admit_turn_through_routing_strategy(model_group, deployment, request_kwargs)
+ await self.async_routing_strategy_pre_call_checks( # pyright: ignore[reportUnknownMemberType] # legacy signature takes an untyped deployment dict
+ deployment=dict(without_io_token_limits(deployment)), parent_otel_span=None
+ )
+ return io_token_admission
+
+ async def _admit_turn_through_routing_strategy(
+ self, model_group: str, deployment: Mapping[str, object], request_kwargs: Mapping[str, object]
+ ) -> None:
+ routing_kwargs: Final = {key: value for key, value in request_kwargs.items() if key != "litellm_logging_obj"}
+ strategy, selector = self._get_routing_context( # pyright: ignore[reportUnknownMemberType] # legacy signature takes an untyped request dict
+ model=model_group, request_kwargs=routing_kwargs
+ )
+ if strategy == "simple-shuffle" or selector is None:
+ return
+ selected: Final = await self._select_deployment_async( # pyright: ignore[reportUnknownMemberType] # legacy signature takes untyped deployment dicts
+ strategy=strategy,
+ selector=selector,
+ model=model_group,
+ healthy_deployments=[dict(deployment)],
+ messages=None,
+ input=None,
+ request_kwargs=routing_kwargs,
+ )
+ if selected is None:
+ no_deployment: Final = await async_raise_no_deployment_exception(
+ ctx=self._core_ctx, model=model_group, parent_otel_span=None
+ )
+ raise litellm_exceptions.RateLimitError(
+ message=str(no_deployment),
+ llm_provider="",
+ model=model_group,
+ category=litellm_exceptions.RateLimitErrorCategory.LITELLM_RATE_LIMIT,
+ ) from no_deployment
+ await self._async_override_selector_pre_call_check( # pyright: ignore[reportUnknownMemberType] # legacy signature takes an untyped deployment dict
+ strategy, selector, dict(deployment), None
+ )
+
+ @contextlib.asynccontextmanager
+ async def _deployment_slot(
+ self, deployment: dict, kwargs: Mapping[str, object], parent_otel_span: Span | None
+ ) -> AsyncGenerator[None, None]:
+ """Holds the deployment's max_parallel_requests slot, if it has one, around the provider call. Routing
+ strategy pre-call checks run inside the slot so their rpm accounting stays concurrency-safe."""
+ max_parallel_requests_limit: Final = self._get_client(
+ deployment=deployment,
+ kwargs=kwargs,
+ client_type="max_parallel_requests",
+ )
+ async with contextlib.AsyncExitStack() as slot:
+ if isinstance(max_parallel_requests_limit, MaxParallelRequestsLimit):
+ slot.enter_context(max_parallel_requests_limit)
+ await self.async_routing_strategy_pre_call_checks(deployment=deployment, parent_otel_span=parent_otel_span)
+ yield
+
+ async def async_callback_filter_deployments(
+ self,
+ model: str,
+ healthy_deployments: list[dict],
+ messages: list[AllMessageValues] | None,
+ parent_otel_span: Span | None,
+ request_kwargs: dict | None = None,
+ logging_obj: LiteLLMLogging | None = None,
+ ):
+ """
+ For usage-based-routing-v2, enables running rpm checks before the call is made, inside the semaphore.
+
+ -> makes the calls concurrency-safe, when rpm limits are set for a deployment
+
+ Returns:
+ - None
+
+ Raises:
+ - Rate Limit Exception - If the deployment is over it's tpm/rpm limits
+ """
+ returned_healthy_deployments = healthy_deployments
+ for _callback in self._routing_callbacks():
+ if isinstance(_callback, CustomLogger):
+ try:
+ returned_healthy_deployments = await _callback.async_filter_deployments(
+ model=model,
+ healthy_deployments=returned_healthy_deployments,
+ messages=messages,
+ request_kwargs=request_kwargs,
+ parent_otel_span=parent_otel_span,
+ )
+ except Exception as e:
+ ## LOG FAILURE EVENT
+ if logging_obj is not None:
+ asyncio.create_task(
+ logging_obj.dispatch_failure_handlers(
+ exception=e,
+ traceback_exception=traceback.format_exc(),
+ prefer_async_handlers=True,
+ )
+ )
+ raise e
+ return returned_healthy_deployments
+
+ @staticmethod
+ def _json_default_stable_id(value: object) -> str:
+ """json.dumps default= for generate_model_id: plain str() on an arbitrary
+ object (e.g. a RoutingPlugin instance) falls back to object.__repr__'s
+ ``, so the hash -- and deployment id -- would
+ change every restart. Use the class name instead, stable across restarts."""
+ return f"{type(value).__module__}.{type(value).__qualname__}"
+
+ @staticmethod
+ def _core_generate_model_id(model_group: str, litellm_params: dict) -> str: # mutable-ok: hashed read-only
+ """The stable id of a deployment without ``model_info.id``; see ``litellm.router_api.deployment_id``."""
+ return _deployment_id(model_group, litellm_params)
+
+ @staticmethod
+ def _inherit_builtin_cache_pricing(model_info: dict, backend_model: str, custom_llm_provider: str | None) -> None:
+ """Fill missing cache pricing on a custom-priced deployment entry from
+ the backend model's built-in cost map entry, so a deployment that
+ only spells out ``input_cost_per_token``/``output_cost_per_token``
+ does not silently bill cache_read/cache_creation at 0.
+
+ User-specified cache fields always win; only ``None``/missing entries
+ are inherited, and a user-set cache_read price also covers explicit
+ cache hits. No-op when the backend model has no canonical entry.
+ """
+ cache_fields: Final = (
+ "cache_creation_input_token_cost",
+ "cache_creation_input_token_cost_above_1hr",
+ "cache_creation_input_token_cost_above_200k_tokens",
+ "cache_read_input_token_cost",
+ "cache_read_input_token_cost_above_200k_tokens",
+ )
+ inherited_fields: Final = (
+ cache_fields
+ if model_info.get("cache_read_input_token_cost") is not None
+ else (*cache_fields, "explicit_cache_read_input_token_cost")
+ )
+ missing_fields: Final = tuple(f for f in inherited_fields if model_info.get(f) is None)
+ if not missing_fields:
+ return
+ try:
+ backend_info: Final = python_host.invoke("get_model_info")(
+ model=backend_model, custom_llm_provider=custom_llm_provider
+ )
+ except Exception:
+ return
+ for field in missing_fields:
+ backend_value = backend_info.get(field)
+ if backend_value is not None:
+ model_info[field] = backend_value
+
+ @staticmethod
+ def _cost_map_backend_model(deployment: Deployment | DeploymentTypedDict) -> str:
+ model_info_base_model: Final = (
+ deployment.model_info.base_model
+ if isinstance(deployment, Deployment)
+ else _configured_model_info(deployment).get("base_model")
+ )
+ if isinstance(model_info_base_model, str) and model_info_base_model:
+ return model_info_base_model
+ params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(
+ deployment.litellm_params.model_dump(exclude_none=True)
+ if isinstance(deployment, Deployment)
+ else deployment["litellm_params"]
+ )
+ params_base_model: Final = params.get("base_model")
+ if isinstance(params_base_model, str) and params_base_model:
+ return params_base_model
+ model: Final = (
+ deployment.litellm_params.model
+ if isinstance(deployment, Deployment)
+ else deployment["litellm_params"].get("model", "")
+ )
+ invoked_model: Final = params.get("model_id")
+ if (
+ isinstance(invoked_model, str)
+ and invoked_model
+ and (params.get("custom_llm_provider") == "bedrock" or model.startswith("bedrock/"))
+ ):
+ profile_model: Final = invoked_model if invoked_model.startswith("bedrock/") else f"bedrock/{invoked_model}"
+ profile_info: Final = lookup_model_info_for_registration(model=profile_model, custom_llm_provider="bedrock")
+ if not isinstance(profile_info, ModelInfoLookupMiss):
+ return profile_model
+ return model
+
+ @staticmethod
+ def _inherit_builtin_service_tier_pricing(
+ model_info: dict, # mutable-ok: deployment cost-map entry filled in place
+ backend_model: str,
+ custom_llm_provider: str | None,
+ ) -> None:
+ """Inherit missing tier rates so a standalone entry does not fall back to custom standard rates."""
+ if ptu_terms(model_info) is not None and is_ptu_cost_attribution_enabled():
+ return
+ if all(model_info.get(field) is None for field in ("input_cost_per_token", "output_cost_per_token")):
+ return
+ try:
+ backend_info: Final = python_host.invoke("get_model_info")(
+ model=backend_model, custom_llm_provider=custom_llm_provider
+ )
+ except Exception: # noqa: BLE001 # get_model_info raises plain Exception for an unmapped backend model
+ return
+ backend_entry: Final = python_host.dynamic().model_cost.get(backend_info.get("key") or "")
+ if not isinstance(backend_entry, dict):
+ return
+ for field, backend_value in backend_entry.items():
+ if not field.endswith(SERVICE_TIER_COST_KEY_SUFFIXES):
+ continue
+ if model_info.get(field) is not None or backend_value is None:
+ continue
+ model_info[field] = copy.deepcopy(backend_value)
+
+ @staticmethod
+ def _inherit_builtin_base_rates_for_off_peak(
+ model_info: dict, # mutable-ok: cost-map entry filled in place
+ backend_model: str,
+ custom_llm_provider: str | None,
+ ) -> None:
+ """Fill missing pricing fields on a deployment entry that only sets
+ ``off_peak_pricing``, from the backend model's built-in cost map entry.
+
+ Cost lookup selects the deployment-scoped entry over the shared backend
+ entry only when the deployment entry carries a base pricing field, and
+ ``off_peak_pricing`` is deliberately kept off the shared entry, so a
+ deployment spelling out only its off-peak schedule would otherwise
+ never receive the discount. The backend model's entire canonical cost
+ map entry is copied, field by field, so threshold, tiered,
+ service-tier, cache, character, and per-second rates as well as
+ companion billing fields like ``web_search_billing_unit`` and the
+ regional uplift multipliers all carry over, and peak-hour billing
+ through the deployment entry matches the shared backend entry exactly.
+ The raw ``litellm.model_cost`` entry is the copy source rather than
+ ``get_model_info``'s view of it, since that view synthesizes zero flat
+ token rates for backends without one and storing those would mark a
+ tiered-only backend explicitly priced free. Values are deep-copied to
+ keep the builtin entry isolated. User-specified fields always win;
+ no-op when any base pricing field is already set or the backend model
+ has no canonical entry.
+ """
+ if not model_info.get("off_peak_pricing"):
+ return
+ if any(
+ model_info.get(field) is not None
+ for field in ("input_cost_per_token", "input_cost_per_second", "cost_per_second", "tiered_pricing")
+ ):
+ return
+ try:
+ backend_info: Final = python_host.invoke("get_model_info")(
+ model=backend_model, custom_llm_provider=custom_llm_provider
+ )
+ except Exception: # noqa: BLE001 # get_model_info raises plain Exception for an unmapped backend model
+ return
+ backend_entry: Final = python_host.dynamic().model_cost.get(backend_info.get("key") or "")
+ if not isinstance(backend_entry, dict):
+ return
+ for field, backend_value in backend_entry.items():
+ if model_info.get(field) is not None or backend_value is None:
+ continue
+ model_info[field] = copy.deepcopy(backend_value)
+
+ @staticmethod
+ def _inherit_builtin_tiered_output_rate(
+ model_info: dict, backend_model: str, custom_llm_provider: str | None
+ ) -> None:
+ """Fill a missing entry-level output rate on a deployment entry whose tier
+ table omits one, from the backend model's built-in cost map entry.
+
+ A deployment's custom pricing is registered as its own standalone
+ ``litellm.model_cost`` entry holding only the supplied fields, and the
+ tiered-cost output fallback reads that same entry, so a tier table that
+ spells out only input-side rates would bill every completion at 0.
+
+ A user-specified ``output_cost_per_token`` always wins. No-op without a
+ tier table, when every tier declares its own output rate, or when the
+ backend model has no canonical entry or no flat output rate:
+ ``get_model_info`` synthesizes a zero for tiered-only backends, and
+ storing that zero would mark the deployment as explicitly priced free.
+ """
+ tiers: Final = model_info.get("tiered_pricing")
+ if not isinstance(tiers, list) or not tiers:
+ return
+ if model_info.get("output_cost_per_token") is not None:
+ return
+ if all(isinstance(tier, dict) and "output_cost_per_token" in tier for tier in tiers):
+ return
+ try:
+ backend_info: Final = python_host.invoke("get_model_info")(
+ model=backend_model, custom_llm_provider=custom_llm_provider
+ )
+ except Exception: # noqa: BLE001 # get_model_info raises plain Exception for an unmapped backend model
+ return
+ backend_rate: Final = backend_info.get("output_cost_per_token")
+ if backend_rate:
+ model_info["output_cost_per_token"] = backend_rate
+
+ def _create_deployment(
+ self,
+ deployment_info: dict,
+ _model_name: str,
+ _litellm_params: dict,
+ _model_info: dict,
+ *,
+ declared_id: str | None = None,
+ duplicate_ids: frozenset[str] = frozenset(),
+ ) -> Deployment | None:
+ """
+ Create a deployment object and add it to the model list
+
+ If the deployment is not active for the current environment, it is ignored
+
+ Returns:
+ - Deployment: The deployment object
+ - None: If the deployment is not active for the current environment (if 'supported_environments' is set in litellm_params)
+ """
+ try:
+ config_sourced: Final = _model_info.get("db_model") is not True
+ identity_error: Final = (
+ ptu_identity_error(
+ declared_id=declared_id,
+ taken=declared_id in duplicate_ids,
+ current_id=_model_info.get("id"),
+ model_name=_model_name,
+ )
+ if config_sourced and ptu_terms(_model_info) is not None
+ else None
+ )
+ ptu_error: Final = (
+ (ptu_config_error(_model_info, model_name=_model_name) or identity_error) if config_sourced else None
+ )
+ declares_split: Final = declares_ptu_shares(_model_info)
+ if ptu_error is not None and (declares_split or is_ptu_cost_attribution_enabled()):
+ raise ValueError(ptu_error)
+ access_windows_error: Final = access_windows_config_error(_model_info, model_name=_model_name)
+ if access_windows_error is not None:
+ raise ValueError(access_windows_error)
+ capacity_warning: Final = (
+ ptu_capacity_warning(
+ _model_name,
+ MappingProxyType(
+ { # pyright: ignore[reportUnknownArgumentType] # router deployment dicts are untyped
+ "model_info": _model_info,
+ "litellm_params": _litellm_params,
+ }
+ ),
+ )
+ if is_ptu_cost_attribution_enabled()
+ else None
+ )
+ if capacity_warning is not None:
+ verbose_router_logger.warning(capacity_warning)
+ zeroed_pricing: Final = zeroed_ptu_pricing(_model_info, _litellm_params) if config_sourced else None
+ merged_params: Final[Mapping[str, object]] = (
+ _litellm_params if zeroed_pricing is None else MappingProxyType({**_litellm_params, **zeroed_pricing})
+ )
+ litellm_params: Final[LiteLLM_Params] = validate_litellm_params_strictly(
+ LiteLLM_Params, dict(**merged_params)
+ )
+ declared_params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(_litellm_params)
+ warn_on_provider_credential_mismatch(model_name=_model_name, litellm_params=declared_params)
+ warn_on_ignored_model_capability_flags(model_name=_model_name, litellm_params=declared_params)
+ warn_on_unenforced_endpoint_flag(model_name=_model_name, model_info=_model_info)
+ deployment = without_model_capability_flags(
+ Deployment(
+ **deployment_info,
+ model_name=_model_name,
+ litellm_params=litellm_params,
+ model_info=_model_info,
+ )
+ )
+ PythonCore._register_deployment_pricing(deployment)
+
+ ## Check if LLM Deployment is allowed for this deployment
+ if self.deployment_is_active_for_environment(deployment=deployment) is not True:
+ verbose_router_logger.warning(
+ "Ignoring deployment %s as it is not active for environment %s",
+ deployment.model_name,
+ deployment.model_info["supported_environments"],
+ )
+ return None
+
+ # Validate tag_regex patterns BEFORE adding the deployment so we never
+ # have partially-initialised router state if a pattern is invalid.
+ _tag_regex: Final = deployment.litellm_params.get("tag_regex") or []
+ for pattern in _tag_regex:
+ try:
+ re.compile(pattern)
+ except re.error as exc:
+ raise ValueError(
+ f"Invalid regex in tag_regex for model '{deployment.model_name}': {pattern!r} — {exc}"
+ ) from exc
+
+ deployment = self._add_deployment(deployment=with_admin_api_key_secret_reference(deployment))
+
+ model: Final = deployment.to_json(exclude_none=True)
+
+ self._add_model_to_list_and_index_map(model=model, model_id=deployment.model_info.id)
+ return deployment
+ except Exception as e:
+ if self.ignore_invalid_deployments:
+ if isinstance(e, litellm_exceptions.BadRequestError):
+ self._provider_unresolved_deployments = (
+ *self._provider_unresolved_deployments,
+ partial(
+ self._create_deployment,
+ deployment_info=deployment_info,
+ _model_name=_model_name,
+ _litellm_params=_litellm_params,
+ _model_info=_model_info,
+ declared_id=declared_id,
+ duplicate_ids=duplicate_ids,
+ ),
+ )
+ verbose_router_logger.exception(
+ "Error creating deployment: %s, ignoring and continuing with other deployments.", e
+ )
+ return None
+ else:
+ raise e
+
+ def _is_auto_router_deployment(self, litellm_params: LiteLLM_Params) -> bool:
+ """
+ Check if the deployment is an auto-router deployment (semantic router).
+
+ Returns True if the litellm_params model starts with "auto_router/"
+ but NOT "auto_router/complexity_router" or "auto_router/adaptive_router"
+ (which use the complexity-router and adaptive-router strategies).
+ """
+ return classify_strategy_router_model(litellm_params.model) == "semantic"
+
+ @staticmethod
+ def _deployment_tags(deployment: Deployment) -> tuple[str, ...]:
+ """Deployment tags used to disambiguate strategy registries keyed by model_name."""
+ return tuple(deployment.litellm_params.tags or ())
+
+ def init_auto_router_deployment(self, deployment: Deployment):
+ """
+ Initialize the auto-router deployment.
+
+ This will initialize the auto-router and add it to the auto-routers dictionary.
+ """
+ from litellm.router_strategy.auto_router.auto_router import AutoRouter
+
+ auto_router_config_path: Final[str | None] = deployment.litellm_params.auto_router_config_path
+ auto_router_config: Final[str | None] = deployment.litellm_params.auto_router_config
+ if auto_router_config_path is None and auto_router_config is None:
+ raise ValueError(
+ "auto_router_config_path or auto_router_config is required for auto-router deployments. Please set it in the litellm_params"
+ )
+
+ default_model: Final[str | None] = deployment.litellm_params.auto_router_default_model
+ if default_model is None:
+ raise ValueError(
+ "auto_router_default_model is required for auto-router deployments. Please set it in the litellm_params"
+ )
+
+ embedding_model: Final[str | None] = deployment.litellm_params.auto_router_embedding_model
+ if embedding_model is None:
+ raise ValueError(
+ "auto_router_embedding_model is required for auto-router deployments. Please set it in the litellm_params"
+ )
+
+ max_input_chars: Final[int | None] = deployment.litellm_params.auto_router_max_input_chars
+
+ autor_router: Final[AutoRouter] = AutoRouter(
+ model_name=deployment.model_name,
+ auto_router_config_path=auto_router_config_path,
+ auto_router_config=auto_router_config,
+ default_model=default_model,
+ embedding_model=embedding_model,
+ ctx=self._core_ctx,
+ max_input_chars=(max_input_chars if max_input_chars is not None else DEFAULT_AUTO_ROUTER_MAX_INPUT_CHARS),
+ )
+ self._register_pre_routing_strategy(
+ registry=self._core_auto_routers,
+ deployment=deployment,
+ strategy=autor_router,
+ strategy_label="Auto-router",
+ )
+
+ def _is_complexity_router_deployment(self, litellm_params: LiteLLM_Params) -> bool:
+ """
+ Check if the deployment is a complexity-router deployment.
+
+ Returns True if the litellm_params model starts with "auto_router/complexity_router"
+ """
+ return classify_strategy_router_model(litellm_params.model) == "complexity"
+
+ def _core_config_deployments(self) -> Iterator[Mapping[str, object]]:
+ """The model_list rows that came from config.yaml rather than the DB (``model_info.db_model`` unset)."""
+ for deployment in self._core_model_list:
+ if not isinstance(deployment, Mapping):
+ continue
+ model_info = deployment.get("model_info")
+ if not (isinstance(model_info, Mapping) and model_info.get("db_model")):
+ yield deployment
+
+ def auto_router_capability_violation(self, capability: GatedAutoRouterCapability) -> str | None:
+ """
+ Why one more router claiming ``capability`` cannot join this router, or None when it can.
+
+ Judged against every deployment currently on the model_list; an upsert pops the row being
+ edited first, so an edit of an existing gated router keeps its own slot. The limit is
+ resolved on every call through ``auto_router_capability_limit``; unset means unlimited,
+ which is the SDK default, and the proxy injects a resolver backed by its license.
+ """
+ limit: Final = self.auto_router_capability_limit() if self.auto_router_capability_limit is not None else None
+ others: Final = count_capability_routers(
+ (deployment for deployment in self._core_model_list if isinstance(deployment, Mapping)),
+ capability=capability,
+ )
+ return capability_limit_violation(capability=capability, held=others + 1, limit=limit)
+
+ def init_complexity_router_deployment(self, deployment: Deployment):
+ """
+ Initialize the complexity-router deployment.
+
+ This will initialize the complexity-router and add it to the complexity-routers dictionary.
+ """
+ # Import here to avoid circular imports — ComplexityRouter is a CustomLogger
+ # subclass that imports litellm internals which depend on router.py.
+ # This matches the AutoRouter pattern in init_auto_router_deployment above.
+ from litellm.router_strategy.complexity_router.complexity_router import (
+ ComplexityRouter,
+ )
+ from litellm.router_strategy.complexity_router.config import (
+ ComplexityRouterConfig,
+ )
+
+ complexity_router_config: Final[dict | None] = deployment.litellm_params.complexity_router_config
+ for capability in claimed_capabilities(complexity_router_config):
+ if (limit_violation := self.auto_router_capability_violation(capability)) is not None:
+ raise ValueError(limit_violation)
+
+ default_model: Final = (
+ ComplexityRouterConfig.model_validate(complexity_router_config).resolve_default_model(
+ deployment.litellm_params.complexity_router_default_model
+ )
+ if complexity_router_config
+ else deployment.litellm_params.complexity_router_default_model
+ )
+
+ if default_model is None:
+ raise ValueError(
+ "complexity_router_default_model is required for complexity-router deployments, "
+ "or configure tiers in complexity_router_config. Please set it in the litellm_params"
+ )
+
+ complexity_router: Final[ComplexityRouter] = ComplexityRouter(
+ model_name=deployment.model_name,
+ default_model=default_model,
+ ctx=self._core_ctx,
+ complexity_router_config=complexity_router_config,
+ )
+ self._register_pre_routing_strategy(
+ registry=self._core_complexity_routers,
+ deployment=deployment,
+ strategy=complexity_router,
+ strategy_label="Complexity-router",
+ )
+ if complexity_router._uses_deployment_pin:
+ self._ensure_deployment_affinity_callback()
+
+ def _is_adaptive_router_deployment(self, litellm_params: LiteLLM_Params) -> bool:
+ """True when this deployment opts in via the `auto_router/adaptive_router` model prefix."""
+ return classify_strategy_router_model(litellm_params.model) == "adaptive"
+
+ def _deployment_participates_in_adaptive_routing(self, litellm_params: LiteLLM_Params) -> bool:
+ """True when this deployment owns an `adaptive_routers` entry once finalized:
+ a dedicated adaptive router, or a complexity router whose config enables the
+ adaptive companion. Mirrors the two arms of
+ `_finalize_adaptive_router_if_configured`, which is the registry's only writer."""
+ if self._is_adaptive_router_deployment(litellm_params=litellm_params):
+ return True
+ if not self._is_complexity_router_deployment(litellm_params=litellm_params):
+ return False
+ config: Final = litellm_params.complexity_router_config
+ if not config:
+ return False
+ adaptive_flag: Final[object] = config.get("adaptive")
+ return bool(adaptive_flag)
+
+ @staticmethod
+ def _has_registered_strategy(
+ registry: dict[str, list[TaggedPreRoutingStrategy[_PreRoutingStrategyT]]],
+ model_name: str,
+ tags: tuple[str, ...],
+ ) -> bool:
+ """True when a strategy for this (model_name, tags) pair is already registered."""
+ return any(existing.tags == tags for existing in registry.get(model_name, []))
+
+ def _register_pre_routing_strategy(
+ self,
+ registry: dict[str, list[TaggedPreRoutingStrategy[_PreRoutingStrategyT]]],
+ deployment: Deployment,
+ strategy: _PreRoutingStrategyT,
+ strategy_label: str,
+ ) -> None:
+ """
+ Register `strategy` under `deployment.model_name`, scoped by its tags.
+ Reusing a `model_name` is allowed when tags differ; a repeat of the same
+ (model_name, tags) pair is a misconfiguration and is rejected.
+ """
+ tags: Final = self._deployment_tags(deployment)
+ if self._has_registered_strategy(registry, deployment.model_name, tags):
+ raise ValueError(
+ f"{strategy_label} deployment {deployment.model_name} with tags {list(tags)} already exists. "
+ "Please use a different model name or set different tags."
+ )
+ registry[deployment.model_name] = [
+ *registry.get(deployment.model_name, []),
+ TaggedPreRoutingStrategy(
+ tags=tags,
+ strategy=strategy,
+ definition_fingerprint=self._routing_definition_fingerprint(deployment),
+ deployment=deployment,
+ ),
+ ]
+
+ @staticmethod
+ def _routing_definition_fingerprint(deployment: Deployment) -> str | None:
+ """Identify configured routing fields, excluding external files and live strategy state."""
+ try:
+ definition: Final = json.dumps(
+ deployment.litellm_params.model_dump(
+ include=STRATEGY_ROUTER_PARAM_FIELDS | {"model", "tags"}, exclude_none=True, warnings=False
+ ),
+ sort_keys=True,
+ default=PythonCore._json_default_stable_id,
+ )
+ except (TypeError, ValueError):
+ return None
+ return PythonCore._core_generate_model_id(deployment.model_name, {"routing_definition": definition})
+
+ @staticmethod
+ def _unregister_pre_routing_strategy(
+ registry: dict[str, list[TaggedPreRoutingStrategy[_PreRoutingStrategyT]]],
+ model_name: str,
+ tags: tuple[str, ...],
+ ) -> bool:
+ """Drop the strategy registered for this exact (model_name, tags) pair, leaving
+ strategies registered under the same name with different tags in place. Returns
+ whether anything was actually dropped."""
+ existing: Final = registry.get(model_name, [])
+ remaining: Final = [entry for entry in existing if entry.tags != tags]
+ if len(remaining) == len(existing):
+ return False
+ if remaining:
+ registry[model_name] = remaining
+ else:
+ registry.pop(model_name, None)
+ return True
+
+ def _unregister_pre_routing_strategy_for_deployment(self, deployment: Deployment) -> None:
+ """
+ Release the pre-routing strategy a deployment holds, so removing it from the
+ model_list also frees its (model_name, tags) slot.
+
+ Without this, re-adding the deployment (an edit arriving via upsert_deployment,
+ or a router recreated under a name that was deleted earlier) hits the
+ "already exists" guard in `_register_pre_routing_strategy`, which
+ `ignore_invalid_deployments` swallows - the deployment then silently never
+ makes it back into the model_list.
+
+ Released from every registry rather than the first match, because registration is
+ one-to-many: a complexity router configured with `adaptive` is also registered in
+ `adaptive_routers` under the same (model_name, tags) by the deferred finalize pass.
+ Guarded on the auto_router/ prefix so removing a *regular* deployment can't evict a
+ router that merely shares its model_name.
+ """
+ if not deployment.litellm_params.model.startswith("auto_router/"):
+ return
+ model_name: Final = deployment.model_name
+ tags: Final = self._deployment_tags(deployment)
+ for registry in (self._core_auto_routers, self._core_complexity_routers, self._core_quality_routers):
+ self._unregister_pre_routing_strategy(registry, model_name, tags)
+ if self._unregister_pre_routing_strategy(self._core_adaptive_routers, model_name, tags):
+ self._sync_adaptive_router_hooks()
+
+ def _finalize_adaptive_router_if_configured(self) -> None:
+ """Locate every adaptive-router deployment in the finalized model_list and
+ build an AdaptiveRouter for each. Safe no-op when none are configured.
+ Idempotent: skips any deployment whose (model_name, tags) pair is already
+ initialized, so hot-reloads don't rebuild routers that would lose state."""
+ for entry in self._core_model_list or []:
+ lp = entry.get("litellm_params") if isinstance(entry, dict) else entry.litellm_params
+ lp_model = (lp.get("model") if isinstance(lp, dict) else lp.model) if lp else None
+ if not (lp_model and lp_model.startswith("auto_router/adaptive_router")):
+ continue
+ model_name = entry.get("model_name") if isinstance(entry, dict) else entry.model_name
+ if not model_name or not lp:
+ continue
+ deployment = Deployment(
+ model_name=model_name,
+ litellm_params=(lp if not isinstance(lp, dict) else LiteLLM_Params.model_validate(lp)),
+ model_info=(entry.get("model_info") if isinstance(entry, dict) else entry.model_info),
+ )
+ if self._has_registered_strategy(
+ self._core_adaptive_routers, model_name, self._deployment_tags(deployment)
+ ):
+ continue
+ self.init_adaptive_router_deployment(deployment=deployment)
+
+ for model_name, tagged_complexity_routers in self._core_complexity_routers.items():
+ for tagged in tagged_complexity_routers:
+ complexity_router = tagged.strategy
+ if not complexity_router.config.adaptive:
+ continue
+ if self._has_registered_strategy(self._core_adaptive_routers, model_name, tagged.tags):
+ continue
+ adaptive_router: AdaptiveRouter | None = complexity_router._ensure_adaptive_router()
+ if adaptive_router is not None:
+ self._core_adaptive_routers[model_name] = [
+ *self._core_adaptive_routers.get(model_name, []),
+ TaggedPreRoutingStrategy(
+ tags=tagged.tags,
+ strategy=adaptive_router,
+ definition_fingerprint=tagged.definition_fingerprint,
+ deployment=tagged.deployment,
+ ),
+ ]
+
+ self._sync_adaptive_router_hooks()
+
+ def _sync_adaptive_router_hooks(self) -> None:
+ """Rebuild the AdaptiveRouterPostCallHook set so it is exactly one hook per
+ currently registered adaptive router. Run at every point the adaptive registry
+ changes, otherwise a released router keeps recording turns through its hook."""
+ from litellm.router_strategy.adaptive_router.hooks import (
+ AdaptiveRouterPostCallHook,
+ AdaptiveRouterResponseHeaderHook,
+ )
+
+ for callback in self._host.callbacks.get_custom_loggers_for_type(AdaptiveRouterPostCallHook):
+ self._host.callbacks.remove_callback_from_all_lists(callback)
+ for tagged_adaptive_routers in self._core_adaptive_routers.values():
+ for tagged in tagged_adaptive_routers:
+ self._host.callbacks.add_litellm_callback(AdaptiveRouterPostCallHook(adaptive_router=tagged.strategy))
+ if self._core_adaptive_routers:
+ self._host.callbacks.add_litellm_callback(AdaptiveRouterResponseHeaderHook())
+
+ def init_adaptive_router_deployment(self, deployment: Deployment) -> None:
+ """
+ Build an AdaptiveRouter instance for this deployment and register its
+ post-call hook. Multiple adaptive routers can coexist on a single Router,
+ keyed by `deployment.model_name`.
+
+ `model_to_prefs` and `model_to_cost` are derived from the OTHER models
+ already registered in `self._core_model_list` whose `model_name` appears in
+ `available_models`. Models not yet registered fall back to defaults.
+ """
+ # Local import: AdaptiveRouter -> hooks -> classifier all import litellm
+ # internals which transitively import this module. (AGENTS.md exception clause.)
+ from litellm.router_strategy.adaptive_router.adaptive_router import (
+ AdaptiveRouter,
+ )
+ from litellm.router_strategy.adaptive_router.hooks import (
+ AdaptiveRouterPostCallHook,
+ )
+ from litellm.types.router import (
+ AdaptiveRouterConfig,
+ AdaptiveRouterPreferences,
+ )
+
+ raw_config: Final = deployment.litellm_params.adaptive_router_config
+ if raw_config is None:
+ raise ValueError("adaptive_router_config is required for adaptive-router deployments.")
+
+ config: Final = AdaptiveRouterConfig(**raw_config)
+
+ model_to_prefs: Final[dict[str, AdaptiveRouterPreferences]] = {}
+ model_to_cost: Final[dict[str, float]] = {}
+ # O(k) via the name→indices map: only touch deployments whose name
+ # is listed in `available_models`, instead of scanning model_list.
+ for name in config.available_models:
+ deployments = self._core_deployments_named(name)
+ if not deployments:
+ continue
+ d = deployments[0]
+ mi_dict: dict[str, Any] = d.get("model_info") or {}
+ prefs_raw = mi_dict.get("adaptive_router_preferences")
+ if prefs_raw is not None:
+ model_to_prefs[name] = AdaptiveRouterPreferences(**prefs_raw)
+
+ # model_info is the conventional pricing location elsewhere in LiteLLM; litellm_params wins if set.
+ cost = d.get("litellm_params", {}).get("input_cost_per_token")
+ if cost is None:
+ cost = mi_dict.get("input_cost_per_token")
+ if cost is not None:
+ model_to_cost[name] = float(cost)
+
+ adaptive_router: Final = AdaptiveRouter(
+ router_name=deployment.model_name,
+ config=config,
+ model_to_prefs=model_to_prefs,
+ model_to_cost=model_to_cost,
+ )
+ self._register_pre_routing_strategy(
+ registry=self._core_adaptive_routers,
+ deployment=deployment,
+ strategy=adaptive_router,
+ strategy_label="Adaptive-router",
+ )
+ self._host.callbacks.add_litellm_callback(AdaptiveRouterPostCallHook(adaptive_router=adaptive_router))
+ verbose_router_logger.info(
+ "AdaptiveRouter[%s] initialized with %d models",
+ deployment.model_name,
+ len(config.available_models),
+ )
+
+ def _is_quality_router_deployment(self, litellm_params: LiteLLM_Params) -> bool:
+ """
+ Check if the deployment is a quality-router deployment.
+
+ Returns True if the litellm_params model starts with "auto_router/quality_router".
+ """
+ return classify_strategy_router_model(litellm_params.model) == "quality"
+
+ def init_quality_router_deployment(self, deployment: Deployment):
+ """
+ Initialize the quality-router deployment.
+
+ Resolves the default model from either `quality_router_default_model` or
+ `quality_router_config["default_model"]`, then instantiates the
+ QualityRouter and stores it in `self._core_quality_routers`.
+ """
+ # Import here to mirror the AutoRouter / ComplexityRouter init pattern
+ # and avoid circular imports.
+ from litellm.router_strategy.quality_router.quality_router import (
+ QualityRouter,
+ )
+
+ quality_router_config: Final[dict | None] = deployment.litellm_params.quality_router_config
+
+ default_model: str | None = deployment.litellm_params.quality_router_default_model
+ if default_model is None and quality_router_config:
+ default_model = quality_router_config.get("default_model")
+
+ if default_model is None:
+ raise ValueError(
+ "quality_router_default_model is required for quality-router deployments, "
+ "or set default_model in quality_router_config. Please configure it in the litellm_params"
+ )
+
+ quality_router: Final[QualityRouter] = QualityRouter(
+ model_name=deployment.model_name,
+ default_model=default_model,
+ ctx=self._core_ctx,
+ quality_router_config=quality_router_config,
+ )
+ self._register_pre_routing_strategy(
+ registry=self._core_quality_routers,
+ deployment=deployment,
+ strategy=quality_router,
+ strategy_label="Quality-router",
+ )
+
+ def deployment_is_active_for_environment(self, deployment: Deployment) -> bool:
+ """
+ Function to check if a llm deployment is active for a given environment. Allows using the same config.yaml across multople environments
+
+ Requires `LITELLM_ENVIRONMENT` to be set in .env. Valid values for environment:
+ - development
+ - staging
+ - production
+
+ Raises:
+ - ValueError: If LITELLM_ENVIRONMENT is not set in .env or not one of the valid values
+ - ValueError: If supported_environments is not set in model_info or not one of the valid values
+ """
+ return model_info_is_active_for_environment(model_info=deployment.model_info)
+
+ def set_model_list(self, model_list: list):
+ original_model_list: Final = copy.deepcopy(model_list)
+ self._discovered_model_info_cache.flush_cache()
+ with self._deployment_index_lock:
+ self._core_model_list = []
+ self.model_id_to_deployment_index_map = {} # Reset the index
+ self._core_model_name_to_deployment_indices = {} # Reset the model_name index
+ self._core_team_model_to_deployment_indices = {} # Reset the team_model index
+ self._core_team_pattern_routers = {}
+ self._core_team_public_model_names = frozenset()
+ # Reset per-strategy router registries so hot-reload doesn't leave
+ # stale routers pointing at the old model_list.
+ self._core_quality_routers = {}
+ self._core_complexity_routers = {}
+ self._core_auto_routers = {}
+ self._provider_unresolved_deployments = ()
+ self._invalidate_model_group_info_cache()
+ self._invalidate_access_groups_cache()
+ # we add api_base/api_key each model so load balancing between azure/gpt on api_base1 and api_base2 works
+
+ declared_ids: Final = tuple(
+ str(entry["model_info"]["id"])
+ for entry in original_model_list
+ if isinstance(entry.get("model_info"), dict) and entry["model_info"].get("id") is not None
+ )
+ duplicate_ids: Final = frozenset(model_id for model_id in declared_ids if declared_ids.count(model_id) > 1)
+
+ ptu_declared: Final = tuple(
+ str(entry.get("model_name"))
+ for entry in original_model_list
+ if isinstance(entry.get("model_info"), dict)
+ and entry["model_info"].get("db_model") is not True
+ and declares_ptu(entry["model_info"])
+ )
+ if ptu_declared and not is_ptu_cost_attribution_enabled():
+ verbose_router_logger.warning(
+ "PTU fields are set on config.yaml deployment(s) %s, but PTU cost attribution is disabled, so no "
+ "flat cost accrues and this traffic is billed per token. Set %s=True to enable it",
+ ", ".join(ptu_declared),
+ PTU_COST_ATTRIBUTION_ENV_VAR,
+ )
+
+ for model in original_model_list:
+ _model_name = model.pop("model_name")
+ _litellm_params = model.pop("litellm_params")
+ ## check if litellm params in os.environ
+ if isinstance(_litellm_params, dict):
+ for k, v in _litellm_params.items():
+ if isinstance(v, str) and v.startswith("os.environ/") and not holds_secret_pointer(k):
+ _litellm_params[k] = get_secret(v)
+
+ _model_info: dict = model.pop("model_info", {})
+
+ declared_id = None if _model_info.get("id") is None else str(_model_info["id"])
+
+ # check if model info has id
+ if "id" not in _model_info:
+ _id = self._core_generate_model_id(_model_name, _litellm_params)
+ _model_info["id"] = _id
+
+ if _litellm_params.get("organization", None) is not None and isinstance(
+ _litellm_params["organization"], list
+ ): # Addresses https://github.com/BerriAI/litellm/issues/3949
+ for org in _litellm_params["organization"]:
+ _litellm_params["organization"] = org
+ self._create_deployment(
+ deployment_info=model,
+ _model_name=_model_name,
+ _litellm_params=_litellm_params,
+ _model_info=_model_info,
+ declared_id=declared_id,
+ duplicate_ids=duplicate_ids,
+ )
+ else:
+ self._create_deployment(
+ deployment_info=model,
+ _model_name=_model_name,
+ _litellm_params=_litellm_params,
+ _model_info=_model_info,
+ declared_id=declared_id,
+ duplicate_ids=duplicate_ids,
+ )
+
+ verbose_router_logger.debug("\nInitialized Model List %s", self._core_get_model_names())
+ self._core_model_names = {m["model_name"] for m in model_list}
+
+ # Note: model_name_to_deployment_indices is already built incrementally
+ # by _create_deployment -> _add_model_to_list_and_index_map
+
+ # Deferred: build the AdaptiveRouter strategy now that all underlying
+ # deployments have been registered.
+ self._finalize_adaptive_router_if_configured()
+
+ def _add_deployment(self, deployment: Deployment) -> Deployment:
+ import os
+
+ #### VALIDATE MODEL ########
+ # Check if this is a prompt management model before validating as LLM provider
+ litellm_model: Final = deployment.litellm_params.model
+ if isinstance(deployment.litellm_params.drop_params, str):
+ verbose_router_logger.warning(
+ "model=%s drop_params=%r is not a flag value, treating it as unset",
+ deployment.model_name,
+ deployment.litellm_params.drop_params,
+ )
+ is_prompt_management_model = False
+
+ if "/" in litellm_model:
+ split_litellm_model: Final = litellm_model.split("/")[0]
+ if split_litellm_model in self._host.dynamic().known_custom_logger_compatible_callbacks:
+ is_prompt_management_model = True
+
+ if is_prompt_management_model:
+ # For prompt management models, skip LLM provider validation
+ # The actual model will be resolved at runtime from the prompt file
+ _model = litellm_model
+ custom_llm_provider = None
+ dynamic_api_key = None
+ api_base = None
+ elif declared_authenticating_provider(litellm_model, deployment.litellm_params.custom_llm_provider) is not None:
+ _model, custom_llm_provider = ( # rebind-ok: each branch resolves the deployment provider
+ self._model_group_llm_provider(deployment.litellm_params)
+ )
+ else:
+ # check if model provider in supported providers
+ (
+ _model,
+ custom_llm_provider,
+ dynamic_api_key,
+ api_base,
+ ) = self._host.invoke("get_llm_provider")(
+ model=deployment.litellm_params.model,
+ custom_llm_provider=deployment.litellm_params.get("custom_llm_provider", None),
+ api_base=deployment.litellm_params.api_base,
+ litellm_params=deployment.litellm_params,
+ )
+ # done reading model["litellm_params"]
+ # Check if provider is supported: either in enum or JSON-configured
+ if (
+ custom_llm_provider not in self._host.dynamic().provider_list
+ and not JSONProviderRegistry.exists(custom_llm_provider)
+ and not is_registered_custom_provider(custom_llm_provider)
+ ):
+ raise Exception(f"Unsupported provider - {custom_llm_provider}")
+
+ #### DEPLOYMENT NAMES INIT ########
+ self._core_deployment_names.append(deployment.litellm_params.model)
+ ############ Users can either pass tpm/rpm as a litellm_param or a router param ###########
+ # for get_available_deployment, we use the litellm_param["rpm"]
+ # in this snippet we also set rpm to be a litellm_param
+ if deployment.litellm_params.rpm is None and getattr(deployment, "rpm", None) is not None:
+ deployment.litellm_params.rpm = getattr(deployment, "rpm")
+
+ if deployment.litellm_params.tpm is None and getattr(deployment, "tpm", None) is not None:
+ deployment.litellm_params.tpm = getattr(deployment, "tpm")
+
+ # Check if user is trying to use model_name == "*"
+ # this is a catch all model for their specific api key
+ # if deployment.model_name == "*":
+ # if deployment.litellm_params.model == "*":
+ # # user wants to pass through all requests to litellm.acompletion for unknown deployments
+ # self._core_router_general_settings.pass_through_all_models = True
+ # else:
+ # self._core_default_deployment = deployment.to_json(exclude_none=True)
+ # Check if user is using provider specific wildcard routing
+ # example model_name = "databricks/*" or model_name = "anthropic/*"
+ if "*" in deployment.model_name:
+ # store this as a regex pattern - all deployments matching this pattern will be sent to this deployment
+ # Store deployment.model_name as a regex pattern
+ self._core_pattern_router.add_pattern(deployment.model_name, deployment.to_json(exclude_none=True))
+ if deployment.model_info.id:
+ self._core_provider_default_deployment_ids.append(deployment.model_info.id)
+
+ _team_id: Final = deployment.model_info.get("team_id")
+ _team_public_model_name: Final[str | None] = deployment.model_info.get("team_public_model_name")
+ if _team_id is not None and _team_public_model_name is not None and "*" in _team_public_model_name:
+ if _team_id not in self._core_team_pattern_routers:
+ self._core_team_pattern_routers[_team_id] = PatternMatchRouter()
+ self._core_team_pattern_routers[_team_id].add_pattern(
+ _team_public_model_name, deployment.to_json(exclude_none=True)
+ )
+
+ # Azure GPT-Vision Enhancements, users can pass os.environ/
+ data_sources: Final = deployment.litellm_params.get("dataSources", []) or []
+
+ for data_source in data_sources:
+ params = data_source.get("parameters", {})
+ for param_key in ["endpoint", "key"]:
+ # if endpoint or key set for Azure GPT Vision Enhancements, check if it's an env var
+ if param_key in params and params[param_key].startswith("os.environ/"):
+ env_name = params[param_key].replace("os.environ/", "")
+ params[param_key] = os.environ.get(env_name, "")
+
+ # # init OpenAI, Azure clients
+ # InitalizeOpenAISDKClient.set_client(
+ # litellm_router_instance=self, model=deployment.to_json(exclude_none=True)
+ # )
+
+ if custom_llm_provider is not None:
+ self._initialize_deployment_for_pass_through(
+ deployment=deployment,
+ custom_llm_provider=custom_llm_provider,
+ )
+
+ #########################################################
+ # Check if this is an auto-router deployment
+ #########################################################
+ if self._is_auto_router_deployment(litellm_params=deployment.litellm_params):
+ self.init_auto_router_deployment(deployment=deployment)
+
+ #########################################################
+ # Check if this is a complexity-router deployment
+ #########################################################
+ if self._is_complexity_router_deployment(litellm_params=deployment.litellm_params):
+ self.init_complexity_router_deployment(deployment=deployment)
+
+ # NOTE: adaptive-router deployments are deferred to the end of
+ # set_model_list() because their init needs visibility into the OTHER
+ # deployments listed in `available_models` (which may not yet have
+ # been processed when this one is created).
+ #########################################################
+ # Check if this is a quality-router deployment
+ #########################################################
+ if self._is_quality_router_deployment(litellm_params=deployment.litellm_params):
+ self.init_quality_router_deployment(deployment=deployment)
+
+ return deployment
+
+ def _initialize_deployment_for_pass_through(self, deployment: Deployment, custom_llm_provider: str):
+ """
+ Optional: Register vertex credentials for pass-through endpoints if `deployment.litellm_params.use_in_pass_through` is True
+
+ Other providers need no registration here: PassthroughEndpointRouter.get_credentials resolves their credentials per-request from the live router deployments
+ """
+ if deployment.litellm_params.use_in_pass_through is not True:
+ return
+ if custom_llm_provider != "vertex_ai":
+ return
+ credential_name: Final = deployment.litellm_params.litellm_credential_name
+ credential_values: Final = (
+ CredentialAccessor.get_credential_values(credential_name) if credential_name is not None else {}
+ )
+ from litellm.types.llms.vertex_ai import VERTEX_CREDENTIALS_TYPES
+
+ vertex_project: Final = (
+ cast("str | None", credential_values.get("vertex_project")) # cast-ok: vertex credential values are str
+ or deployment.litellm_params.vertex_project
+ )
+ vertex_location: Final = (
+ cast("str | None", credential_values.get("vertex_location")) # cast-ok: vertex credential values are str
+ or deployment.litellm_params.vertex_location
+ )
+ vertex_credentials: Final = (
+ cast( # cast-ok: vertex_credentials holds the typed credential union
+ "VERTEX_CREDENTIALS_TYPES | None", credential_values.get("vertex_credentials")
+ )
+ or deployment.litellm_params.vertex_credentials
+ )
+
+ if vertex_project is None or vertex_location is None:
+ raise ValueError(
+ "vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints."
+ )
+ router_host().add_vertex_pass_through_credentials(
+ project_id=vertex_project,
+ location=vertex_location,
+ vertex_credentials=vertex_credentials,
+ )
+
+ def add_deployment(self, deployment: Deployment) -> Deployment | None:
+ """
+ Parameters:
+ - deployment: Deployment - the deployment to be added to the Router
+
+ Returns:
+ - The added deployment
+ - OR None (if deployment already exists)
+ """
+ # check if deployment already exists
+
+ _deployment_model_id: Final = deployment.model_info.id
+ if _deployment_model_id and self._core_has_model_id(_deployment_model_id):
+ return None
+
+ declared_params: Final = deployment.litellm_params.model_dump(exclude_none=True)
+ warn_on_provider_credential_mismatch(model_name=deployment.model_name, litellm_params=declared_params)
+ warn_on_ignored_model_capability_flags(model_name=deployment.model_name, litellm_params=declared_params)
+ warn_on_unenforced_endpoint_flag(
+ model_name=deployment.model_name, model_info=deployment.model_info.model_dump()
+ )
+ served: Final = with_admin_api_key_secret_reference(without_model_capability_flags(deployment))
+
+ # add to model list
+ _deployment: Final = served.to_json(exclude_none=True)
+ # initialize client
+ self._add_deployment(deployment=served)
+
+ PythonCore._register_deployment_pricing(deployment=served)
+
+ # add to model names
+ self._add_model_to_list_and_index_map(model=_deployment, model_id=served.model_info.id)
+ self._core_model_names.add(served.model_name)
+ self._sync_deployment_budget_config(deployment=served)
+ return served
+
+ def _update_deployment_indices_after_removal(self, model_id: str, removal_idx: int) -> None:
+ """
+ Helper method to update deployment indices after a deployment has been removed from model_list.
+
+ Parameters:
+ - model_id: str - the id of the deployment that was removed
+ - removal_idx: int - the index where the deployment was removed from model_list
+ """
+ self._discovered_model_info_cache.delete_cache(model_id)
+ # Update indices for all models after the removed one
+ for deployment_id, idx in self.model_id_to_deployment_index_map.items():
+ if idx > removal_idx:
+ self.model_id_to_deployment_index_map[deployment_id] = idx - 1
+ # Remove the deleted model from index
+ if model_id in self.model_id_to_deployment_index_map:
+ del self.model_id_to_deployment_index_map[model_id]
+
+ # Update model_name_to_deployment_indices
+ for model_name, indices in list(self._core_model_name_to_deployment_indices.items()):
+ # Build new list without mutating the original
+ updated_indices = []
+ for idx in indices:
+ if idx == removal_idx:
+ # Skip the removed index
+ continue
+ elif idx > removal_idx:
+ # Decrement indices after removal
+ updated_indices.append(idx - 1)
+ else:
+ # Keep indices before removal unchanged
+ updated_indices.append(idx)
+
+ # Update or remove the entry
+ if len(updated_indices) > 0:
+ self._core_model_name_to_deployment_indices[model_name] = updated_indices
+ else:
+ del self._core_model_name_to_deployment_indices[model_name]
+ self._core_model_names.discard(model_name)
+
+ # Update team_model_to_deployment_indices
+ for key, indices in list(self._core_team_model_to_deployment_indices.items()):
+ # Build new list without mutating the original
+ updated_indices = []
+ for idx in indices:
+ if idx == removal_idx:
+ # Skip the removed index
+ continue
+ elif idx > removal_idx:
+ # Decrement indices after removal
+ updated_indices.append(idx - 1)
+ else:
+ # Keep indices before removal unchanged
+ updated_indices.append(idx)
+
+ # Update or remove the entry
+ if len(updated_indices) > 0:
+ self._core_team_model_to_deployment_indices[key] = updated_indices
+ else:
+ del self._core_team_model_to_deployment_indices[key]
+ self._core_team_public_model_names = frozenset(
+ public_model_name for _, public_model_name in self._core_team_model_to_deployment_indices
+ )
+
+ self._core_pattern_router.remove_deployment(model_id)
+ for team_id in list(self._core_team_pattern_routers.keys()):
+ team_pattern_router = self._core_team_pattern_routers[team_id]
+ team_pattern_router.remove_deployment(model_id)
+ if not team_pattern_router.patterns:
+ del self._core_team_pattern_routers[team_id]
+
+ def _update_team_model_index(self, model: dict, idx: int) -> None:
+ """
+ Helper to update team_model_to_deployment_indices for a single deployment.
+
+ Parameters:
+ - model: dict - the deployment to index
+ - idx: int - the index in model_list
+ """
+ team_id: Final = (model.get("model_info") or {}).get("team_id")
+ team_public_model_name: Final = (model.get("model_info") or {}).get("team_public_model_name")
+ if team_id and team_public_model_name:
+ key: Final = (team_id, team_public_model_name)
+ self._core_team_public_model_names = self._core_team_public_model_names | frozenset(
+ {team_public_model_name}
+ )
+ if key not in self._core_team_model_to_deployment_indices:
+ self._core_team_model_to_deployment_indices[key] = []
+ if idx not in self._core_team_model_to_deployment_indices[key]:
+ self._core_team_model_to_deployment_indices[key].append(idx)
+
+ def _add_model_to_list_and_index_map(self, model: dict, model_id: str | None = None) -> None:
+ """
+ Helper method to add a model to the model_list and update both indices.
+
+ Parameters:
+ - model: dict - the model to add to the list
+ - model_id: Optional[str] - the model ID to use for indexing. If None, will try to get from model["model_info"]["id"]
+ """
+ with self._deployment_index_lock:
+ idx: Final = len(self._core_model_list)
+ self._core_model_list.append(model)
+ self._invalidate_model_group_info_cache()
+ self._invalidate_access_groups_cache()
+
+ # Update model_id index for O(1) lookup
+ if model_id is not None:
+ self.model_id_to_deployment_index_map[model_id] = idx
+ elif model.get("model_info", {}).get("id") is not None:
+ self.model_id_to_deployment_index_map[model["model_info"]["id"]] = idx
+
+ # Update model_name index for O(1) lookup
+ model_name: Final = model.get("model_name")
+ if model_name:
+ if model_name not in self._core_model_name_to_deployment_indices:
+ self._core_model_name_to_deployment_indices[model_name] = []
+ self._core_model_name_to_deployment_indices[model_name].append(idx)
+
+ # Update team_model index for O(1) team-scoped lookup
+ self._update_team_model_index(model, idx)
+
+ def _core_upsert_deployment(self, deployment: Deployment) -> Deployment | None:
+ """
+ Add or update deployment
+ Parameters:
+ - deployment: Deployment - the deployment to be added to the Router
+
+ Returns:
+ - The added/updated deployment
+ """
+ _deployment_model_id: Final = deployment.model_info.id or ""
+ _deployment_on_router: Final[Deployment | None] = self._core_get_deployment(model_id=_deployment_model_id)
+ served: Final = without_model_capability_flags(deployment)
+ try:
+ if _deployment_on_router is not None:
+ # deployment with this model_id exists on the router
+ if (
+ deployment.model_name == _deployment_on_router.model_name
+ and served.litellm_params == _deployment_on_router.litellm_params
+ and deployment.model_info == _deployment_on_router.model_info
+ ):
+ # No need to update
+ return None
+
+ # if there is a new litellm param -> then update the deployment
+ # remove the previous deployment
+ with self._deployment_index_lock:
+ removal_idx: Final = self.model_id_to_deployment_index_map.get(_deployment_model_id)
+ if removal_idx is not None:
+ self._core_model_list.pop(removal_idx)
+ self._invalidate_model_group_info_cache()
+ self._invalidate_access_groups_cache()
+ self._update_deployment_indices_after_removal(
+ model_id=_deployment_model_id, removal_idx=removal_idx
+ )
+
+ # Free the outgoing deployment's pre-routing strategy slot (keyed by the
+ # OLD model_name/tags) before the re-add below re-registers it.
+ self._unregister_pre_routing_strategy_for_deployment(deployment=_deployment_on_router)
+
+ # if the model_id is not in router
+ self.add_deployment(deployment=deployment)
+ # add_deployment() builds every strategy EXCEPT the adaptive one, which
+ # set_model_list() defers until the whole model_list is visible. Re-run that
+ # deferred pass so an adaptive router whose slot was just released above is
+ # rebuilt rather than left unregistered.
+ if self._deployment_participates_in_adaptive_routing(litellm_params=deployment.litellm_params) or (
+ _deployment_on_router is not None
+ and self._deployment_participates_in_adaptive_routing(
+ litellm_params=_deployment_on_router.litellm_params
+ )
+ ):
+ self._finalize_adaptive_router_if_configured()
+ return deployment
+ except Exception as e:
+ if self.ignore_invalid_deployments:
+ verbose_router_logger.warning(
+ "Error upserting deployment %s (id=%s): %s. Dropping it and continuing with other deployments.",
+ deployment.model_name,
+ deployment.model_info.id,
+ e,
+ )
+ self._restore_deployment_after_failed_upsert(
+ previous_deployment=_deployment_on_router, model_id=_deployment_model_id
+ )
+ return None
+ else:
+ raise e
+
+ def _restore_deployment_after_failed_upsert(self, previous_deployment: Deployment | None, model_id: str) -> None:
+ """Put a deployment back the way it was before a failed upsert popped it.
+
+ A rollback re-admits state that was already serving, so it does not go through the
+ capability ceiling a newcomer gets: with the ceiling tightened since the deployment first
+ registered, judging the rollback would drop a serving router over an unrelated failed edit.
+ """
+ if previous_deployment is None or self._core_has_model_id(model_id):
+ return
+ limit_resolver: Final = self.auto_router_capability_limit
+ self.auto_router_capability_limit = None
+ try:
+ self.add_deployment(deployment=previous_deployment)
+ verbose_router_logger.info(
+ "Restored deployment %s (id=%s); it keeps serving its previous configuration.",
+ previous_deployment.model_name,
+ model_id,
+ )
+ except Exception as restore_error: # noqa: BLE001 # best-effort restore: a second failure must not abort the reload
+ verbose_router_logger.warning(
+ "Could not restore previously served deployment %s (id=%s) after the failed upsert: %s",
+ previous_deployment.model_name,
+ model_id,
+ restore_error,
+ )
+ finally:
+ self.auto_router_capability_limit = limit_resolver
+
+ @staticmethod
+ def _backend_cost_map_keys(model: str, custom_llm_provider: str | None) -> tuple[str, ...]:
+ """The ``litellm.model_cost`` keys a deployment's shared backend info is registered under."""
+ backend_key: Final = model if custom_llm_provider is None else f"{custom_llm_provider}/{model}"
+ if "responses/" in backend_key:
+ return (backend_key, backend_key.replace("responses/", ""))
+ return (backend_key,)
+
+ @staticmethod
+ def _deployment_model_cost_payload(deployment: Deployment) -> dict[str, object]: # mutable-ok: cost-map entry
+ """The ``model_info`` a deployment contributes to ``litellm.model_cost``.
+
+ Custom pricing lives on ``litellm_params`` rather than ``model_info``, and
+ the built-in cache-pricing inheritance is derived rather than stored, so
+ both are folded back in here. That keeps this reproducible from a
+ deployment alone, which is what lets a refresh rebuild the same entries.
+ """
+ model_info: Final = dict(
+ _MODEL_INFO_ADAPTER.validate_python(deployment.model_info.model_dump(exclude_none=True))
+ )
+ for field in CustomPricingLiteLLMParams.model_fields:
+ field_value = deployment.litellm_params.get(field)
+ if field_value is not None:
+ model_info[field] = field_value
+ backend_model: Final = PythonCore._cost_map_backend_model(deployment)
+ pricing_provider: Final = (
+ deployment.model_info.pricing_provider or deployment.litellm_params.custom_llm_provider
+ )
+ params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(deployment.litellm_params.model_dump(exclude_none=True))
+ if (
+ params.get("model_id")
+ and backend_model != deployment.litellm_params.model
+ and (
+ deployment.litellm_params.custom_llm_provider == "bedrock"
+ or deployment.litellm_params.model.startswith("bedrock/")
+ )
+ ):
+ profile_info: Final = lookup_model_info_for_registration(model=backend_model, custom_llm_provider="bedrock")
+ if not isinstance(profile_info, ModelInfoLookupMiss):
+ explicit_info: Final = _MODEL_INFO_ADAPTER.validate_python(model_info)
+ priced_info: Final = model_info_with_pricing_overrides(profile_info, explicit_info)
+ model_info.update(
+ {key: value for key, value in priced_info.items() if is_server_derived_pricing_key(key)}
+ )
+ PythonCore._inherit_builtin_base_rates_for_off_peak(
+ model_info=model_info,
+ backend_model=backend_model,
+ custom_llm_provider=pricing_provider,
+ )
+ if model_info.get("input_cost_per_token") is not None:
+ PythonCore._inherit_builtin_cache_pricing(
+ model_info=model_info,
+ backend_model=backend_model,
+ custom_llm_provider=pricing_provider,
+ )
+ PythonCore._inherit_builtin_service_tier_pricing(
+ model_info=model_info,
+ backend_model=backend_model,
+ custom_llm_provider=pricing_provider,
+ )
+ PythonCore._inherit_builtin_tiered_output_rate(
+ model_info=model_info,
+ backend_model=backend_model,
+ custom_llm_provider=pricing_provider,
+ )
+ return model_info
+
+ @staticmethod
+ def _register_deployment_pricing(deployment: Deployment) -> None:
+ """Register a deployment's custom/inherited pricing in ``litellm.model_cost``.
+
+ Takes only a ``Deployment``, so it registers pricing for a deployment that
+ is never added to ``self._core_model_list`` (a per-request client-side-credential
+ deployment) just as readily as one that is.
+ """
+ PythonCore._register_deployment_in_model_cost(
+ model_id=deployment.model_info.id,
+ model_info=PythonCore._deployment_model_cost_payload(deployment),
+ model=deployment.litellm_params.model,
+ custom_llm_provider=deployment.litellm_params.custom_llm_provider,
+ pricing_provider=deployment.model_info.pricing_provider,
+ )
+
+ @staticmethod
+ def _register_deployment_in_model_cost(
+ *,
+ model_id: str | None,
+ model_info: dict, # mutable-ok: cost-map entry
+ model: str,
+ custom_llm_provider: str | None,
+ pricing_provider: str | None = None,
+ ) -> None:
+ """Write a deployment's metadata into ``litellm.model_cost``.
+
+ Runs when a deployment is added and again after a price data reload, so
+ the entries a refresh rebuilds are the ones a fresh boot would produce.
+ Nothing is recorded for replay: a refresh walks the live routers instead,
+ so a deleted, repointed or never-added deployment, and a discarded router,
+ drop out of the rebuild on their own.
+
+ A strategy-router alias is never the deployment actually called or
+ billed, so custom pricing configured on it must not become a cost-map
+ price: an explicit zero would let ``_is_cost_explicitly_configured``
+ treat the alias as a genuinely free model and waive budget checks for
+ requests that route to (and bill as) a real deployment.
+ """
+ if classify_strategy_router_model(model) is not None:
+ model_info = {k: v for k, v in model_info.items() if k not in CustomPricingLiteLLMParams.model_fields}
+
+ if model_id is not None:
+ python_host.invoke("register_model")(
+ model_cost={model_id: model_info},
+ persist_across_reloads=False,
+ warning_display_name=model,
+ custom_llm_provider=pricing_provider or custom_llm_provider,
+ add_to_provider_model_sets=False,
+ )
+
+ ## OLD MODEL REGISTRATION ## Kept to prevent breaking changes
+ backend_keys: Final = PythonCore._backend_cost_map_keys(model=model, custom_llm_provider=custom_llm_provider)
+
+ # For the shared backend key, keep only cost-map schema fields
+ # (minus custom pricing) so that one deployment's pricing overrides
+ # or custom metadata (id, access_via_team_ids, arbitrary keys)
+ # don't pollute another deployment sharing the same backend model
+ # name. Each deployment's full model_info is already stored under
+ # its unique model_id above.
+ shared_model_info: Final = shared_backend_model_info(model_info)
+ python_host.invoke("register_model")(
+ model_cost={_key: keep_registered_mode(_key, shared_model_info) for _key in backend_keys},
+ persist_across_reloads=False,
+ )
+
+ def replay_model_cost_registrations(self) -> None:
+ """Re-assert this router's deployments onto a freshly fetched catalog.
+
+ Reads ``model_list`` at call time, so only deployments the router still
+ serves are restored, plus any config deployment the fresh catalog now resolves.
+ """
+ provider_unresolved: Final = self._provider_unresolved_deployments
+ self._provider_unresolved_deployments = ()
+ for create_deployment in provider_unresolved:
+ create_deployment()
+ for entry in tuple(self._core_model_list):
+ try:
+ deployment = entry if isinstance(entry, Deployment) else Deployment(**entry)
+ except Exception: # noqa: BLE001 # a malformed entry must not abort the rest of the rebuild
+ verbose_router_logger.exception(
+ "Router: could not rebuild cost-map entry for a deployment during a price data reload"
+ )
+ continue
+ PythonCore._register_deployment_in_model_cost(
+ model_id=deployment.model_info.id,
+ model_info=PythonCore._deployment_model_cost_payload(deployment),
+ model=deployment.litellm_params.model,
+ custom_llm_provider=deployment.litellm_params.custom_llm_provider,
+ pricing_provider=deployment.model_info.pricing_provider,
+ )
+ self._invalidate_model_group_info_cache()
+
+ _replay_model_cost_registrations = replay_model_cost_registrations
+
+ def _core_delete_deployment(self, id: str) -> Deployment | None:
+ """
+ Parameters:
+ - id: str - the id of the deployment to be deleted
+
+ Returns:
+ - The deleted deployment
+ - OR None (if deleted deployment not found)
+ """
+ try:
+ with self._deployment_index_lock:
+ deployment_idx: Final = self.model_id_to_deployment_index_map.get(id)
+ if deployment_idx is None:
+ return None
+ # Pop the item from the list first
+ item: Final = self._core_model_list.pop(deployment_idx)
+ self._invalidate_model_group_info_cache()
+ self._invalidate_access_groups_cache()
+ self._update_deployment_indices_after_removal(model_id=id, removal_idx=deployment_idx)
+ _budget_limiter: Final = self._core_get_router_deployment_budget_limiter()
+ if _budget_limiter is not None:
+ _budget_limiter.unregister_deployment_budget(model_id=id)
+ try:
+ self._unregister_pre_routing_strategy_for_deployment(
+ deployment=item if isinstance(item, Deployment) else Deployment(**item)
+ )
+ except Exception:
+ verbose_router_logger.exception(
+ "delete_deployment: could not release pre-routing strategies for model_id=%s; "
+ "the deployment is out of the model_list and its indices are repaired",
+ id,
+ )
+ except Exception:
+ return None
+ return item
+
+ def _core_get_router_deployment_budget_limiter(
+ self,
+ ) -> RouterBudgetLimiting | None:
+ """
+ Return the router's deployment-budget callback.
+
+ Uses exact-type matching so proxy subclasses (e.g. virtual-key model budgets)
+ registered on litellm.callbacks are not mistaken for router deployment budgets.
+ """
+ if self.router_budget_logger is not None:
+ return self.router_budget_logger
+
+ if self.optional_callbacks:
+ for _cb in self.optional_callbacks:
+ if isinstance(_cb, RouterBudgetLimiting) and type(_cb) is RouterBudgetLimiting:
+ self.router_budget_logger = _cb
+ return _cb
+ return None
+
+ def _deployment_has_budget_limits(self, deployment: Deployment) -> bool:
+ return (
+ deployment.litellm_params.get("max_budget") is not None
+ and deployment.litellm_params.get("budget_duration") is not None
+ and deployment.model_info.id is not None
+ )
+
+ def _sync_deployment_budget_config(self, deployment: Deployment) -> None:
+ model_id: Final = deployment.model_info.id
+ if model_id is None:
+ return
+
+ _budget_limiter = self._core_get_router_deployment_budget_limiter()
+
+ if not self._deployment_has_budget_limits(deployment=deployment):
+ if _budget_limiter is not None:
+ _budget_limiter.unregister_deployment_budget(model_id=model_id)
+ return
+
+ if _budget_limiter is None:
+ self.add_optional_pre_call_checks(optional_pre_call_checks=["router_budget_limiting"])
+ _budget_limiter = self._core_get_router_deployment_budget_limiter()
+
+ if _budget_limiter is not None:
+ _budget_limiter.register_deployment_budget(deployment=deployment.to_json(exclude_none=True))
+
+ def _core_deployments_named(self, model_name: str) -> tuple[DeploymentTypedDict, ...] | None:
+ """The deployments indexed under `model_name`, read under the index lock, or None when the name is not indexed."""
+ with self._deployment_index_lock:
+ indices: Final = self._core_model_name_to_deployment_indices.get(model_name)
+ return None if indices is None else tuple(self._core_model_list[idx] for idx in indices)
+
+ def _served_openai_models(self, model_name: str) -> tuple[str | None, ...]:
+ return served_openai_models(self._core_deployments_named(model_name) or ())
+
+ def team_public_model_names_of(self, team_id: str) -> frozenset[str]:
+ """The public names `team_id` serves its own deployments under."""
+ with self._deployment_index_lock:
+ return frozenset(name for owner, name in self._core_team_model_to_deployment_indices if owner == team_id)
+
+ def _team_deployments_named(self, team_id: str, public_model_name: str) -> tuple[DeploymentTypedDict, ...] | None:
+ """The deployments `team_id` serves under `public_model_name`, or None when that pair is not indexed."""
+ with self._deployment_index_lock:
+ indices: Final = self._core_team_model_to_deployment_indices.get((team_id, public_model_name))
+ return None if indices is None else tuple(self._core_model_list[idx] for idx in indices)
+
+ def _core_get_deployment(self, model_id: str) -> Deployment | None:
+ """
+ Returns -> Deployment or None
+
+ Raise Exception -> if model found in invalid format
+ """
+ # Use O(1) lookup via model_id_to_deployment_index_map only
+ with self._deployment_index_lock:
+ idx: Final = self.model_id_to_deployment_index_map.get(model_id)
+ model: Final = None if idx is None else self._core_model_list[idx]
+ if model is not None:
+ if isinstance(model, dict):
+ return Deployment(**model)
+ elif isinstance(model, Deployment):
+ return model
+ else:
+ raise Exception(f"Model invalid format - {type(model)}")
+
+ return None
+
+ def _core_get_deployment_credentials(self, model_id: str) -> dict | None:
+ """
+ Returns -> dict of credentials for a given model id.
+
+ Returns None if the deployment is paused via `LiteLLM_ProxyModelTable.blocked`,
+ so file/batch/passthrough callers that resolve credentials directly cannot keep
+ using a paused deployment.
+ """
+ deployment: Final = self._core_get_deployment(model_id=model_id)
+ if deployment is None or self._core_is_deployment_blocked(deployment):
+ return None
+ return CredentialLiteLLMParams.model_validate(
+ deployment.litellm_params.model_dump(exclude_none=True)
+ ).model_dump(exclude_none=True)
+
+ def _core_get_deployment_by_model_group_name(self, model_group_name: str) -> Deployment | None:
+ """
+ Returns -> Deployment or None
+
+ Raise Exception -> if model found in invalid format
+
+ Optimized with O(1) index lookup instead of O(n) linear scan.
+ """
+ # O(1) lookup in model_name index
+ with self._deployment_index_lock:
+ indices: Final = self._core_model_name_to_deployment_indices.get(model_group_name)
+ # Return first deployment for this model_name
+ model: Final = self._core_model_list[indices[0]] if indices else None
+ if model is not None:
+ if isinstance(model, dict):
+ return Deployment(**model)
+ elif isinstance(model, Deployment):
+ return model
+ else:
+ raise Exception(f"Model Name invalid - {type(model)}")
+ return None
+
+ @staticmethod
+ def _core_deployment_usable_by_team(model: Mapping[str, object] | Deployment, team_id: str | None) -> bool:
+ """
+ A team-scoped deployment (``model_info.team_id`` set) is only usable by
+ callers from that same team; deployments without a team owner are shared.
+ """
+ model_info: Final = model.get("model_info") if isinstance(model, dict) else model.model_info
+ owner_team_id: Final = cast(Mapping[str, object], model_info).get("team_id") if model_info is not None else None
+ return team_may_use_deployment(owner_team_id, team_id)
+
+ def _get_model_group_deployment_usable_by_team(
+ self, model_group_name: str, team_id: str | None
+ ) -> Deployment | None:
+ """
+ Like ``get_deployment_by_model_group_name``, but skips deployments owned
+ by other teams so a shared model name never resolves another team's
+ credentials.
+ """
+ with self._deployment_index_lock:
+ indices: Final = self._core_model_name_to_deployment_indices.get(model_group_name) or ()
+ usable: Final = (
+ self._core_model_list[idx]
+ for idx in indices
+ if self._core_deployment_usable_by_team(self._core_model_list[idx], team_id)
+ )
+ first_usable: Final = next(usable, None)
+ if first_usable is None:
+ return None
+ return Deployment(**first_usable) if isinstance(first_usable, dict) else first_usable
+
+ async def _core_arefresh_model_info(self, *, client: AsyncHTTPHandler | None = None) -> None:
+ """Refresh token limits advertised by configured OpenAI-compatible deployments."""
+ deployments: Final = iter(tuple(self._core_model_list))
+
+ async def refresh_worker() -> None:
+ for raw_deployment in deployments:
+ try:
+ await self._arefresh_deployment_model_info(raw_deployment, client=client)
+ except Exception: # noqa: BLE001 # one invalid deployment must not prevent refreshing the others
+ verbose_router_logger.debug("Could not refresh deployment model info")
+
+ await asyncio.gather(*(refresh_worker() for _ in range(MODEL_INFO_REFRESH_CONCURRENCY)))
+ self._invalidate_model_group_info_cache()
+
+ async def _arefresh_deployment_model_info(
+ self, raw_deployment: Mapping[str, object], *, client: AsyncHTTPHandler | None
+ ) -> None:
+ deployment: Final = Deployment.model_validate(raw_deployment)
+ params: Final = LiteLLM_Params.model_validate(
+ MappingProxyType(
+ {
+ **deployment.litellm_params.model_dump(exclude_none=True),
+ **(
+ self._core_get_deployment_credentials_with_provider(deployment.model_info.id or "")
+ or MappingProxyType({})
+ ),
+ }
+ )
+ )
+ model, provider, dynamic_api_key, api_base = self._host.invoke("get_llm_provider")(
+ model=params.model, litellm_params=params
+ )
+ if provider not in MODEL_INFO_DISCOVERY_PROVIDERS:
+ return
+ if api_base is None or "*" in model or params.get("use_clientside_credentials"):
+ return
+ api_key: Final = await aresolve_api_key_secret_reference(
+ params.api_key or dynamic_api_key,
+ model=model,
+ llm_provider=provider,
+ upstream=MappingProxyType({"api_base": params.api_base}),
+ )
+ headers: Final = TypeAdapter(Mapping[str, str]).validate_python(
+ params.get("extra_headers") or params.get("headers") or MappingProxyType({})
+ )
+ auth_headers: Final = (
+ MappingProxyType({"authorization": f"Bearer {api_key}"}) if api_key else MappingProxyType({})
+ )
+ limits: Final = await get_openai_compatible_model_info(
+ model=model,
+ api_base=api_base,
+ headers=MappingProxyType(
+ {
+ **auth_headers,
+ **MappingProxyType({key.lower(): value for key, value in headers.items()}),
+ }
+ ),
+ client=client or get_async_httpx_client(llm_provider=LlmProviders.OPENAI),
+ cache=self._core_cache.in_memory_cache,
+ )
+ model_id: Final = deployment.model_info.id
+ if not limits or model_id is None or self._core_get_model_info(model_id) is not raw_deployment:
+ return
+ self._discovered_model_info_cache.max_size_in_memory = max(len(self._core_model_list), 1)
+ self._discovered_model_info_cache.delete_cache(model_id)
+ self._discovered_model_info_cache.set_cache(
+ model_id, DiscoveredDeploymentModelInfo(deployment=raw_deployment, limits=limits)
+ )
+ self._invalidate_model_group_info_cache()
+
+ def _core_get_discovered_model_info(self, model_id: str | None) -> Mapping[str, int]:
+ cached: Final[object] = self._discovered_model_info_cache.get_cache(model_id)
+ if (
+ model_id is not None
+ and isinstance(cached, DiscoveredDeploymentModelInfo)
+ and cached.deployment is self._core_get_model_info(model_id)
+ ):
+ configured: Final = TypeAdapter(Mapping[str, object]).validate_python(cached.deployment["model_info"])
+ return MappingProxyType({key: value for key, value in cached.limits.items() if configured.get(key) is None})
+ return MappingProxyType({})
+
+ def _core_get_model_listing_info(self, model_name: str) -> DeploymentModelListingInfo | None:
+ """
+ Return what the concrete deployments behind model_name contribute to its
+ /v1/models entry: the cost-map keys for their underlying models, plus the widest
+ configured or discovered token limits. Resolved via O(1) index
+ lookup.
+
+ Returns None for wildcard-expanded or unknown names, where the listed name is the
+ real model name and no deployment-specific information exists, and treats a
+ malformed configured limit as absent rather than failing the listing.
+
+ The whole group is read rather than just its first deployment, so a group that
+ mixes models does not advertise a window that depends on config order; the widest
+ one is reported, which is what get_model_group_info already shows the Admin UI.
+ Keys are deduplicated, so the ordinary group of interchangeable deployments of one
+ model still costs the caller a single cost-map lookup. Unlike get_model_group_info,
+ this never triggers pattern matching or deep copies, so it is safe to call per
+ listed model on the /v1/models hot path.
+ """
+ deployments: Final = self._core_deployments_named(model_name)
+ if not deployments:
+ return None
+
+ model_infos: Final = tuple(
+ MappingProxyType(
+ {
+ **self._core_get_discovered_model_info(
+ (deployment.get("model_info") or MappingProxyType({})).get("id")
+ ),
+ **MappingProxyType(
+ {
+ k: v
+ for k, v in (deployment.get("model_info") or MappingProxyType({})).items()
+ if v is not None
+ }
+ ),
+ }
+ )
+ for deployment in deployments
+ )
+ raw_deployments: Final[tuple[object, ...]] = deployments
+ pricing_keys: Final = (deployment_pricing_models(deployment) for deployment in raw_deployments)
+ cost_map_keys: Final = tuple(dict.fromkeys(keys[0] for keys in pricing_keys if keys))
+ return DeploymentModelListingInfo(
+ cost_map_keys=cost_map_keys,
+ max_input_tokens=self._widest_configured_limit(model_infos, "max_input_tokens"),
+ max_output_tokens=self._widest_configured_limit(model_infos, "max_output_tokens"),
+ )
+
+ @staticmethod
+ def _widest_configured_limit(model_infos: Sequence[Mapping[str, object]], field: str) -> int | None:
+ """The largest usable value of ``field`` across a group's configured model_info blocks."""
+ limits: Final = tuple(
+ limit
+ for limit in (coerce_token_limit(model_info.get(field)) for model_info in model_infos)
+ if limit is not None
+ )
+ return max(limits) if limits else None
+
+ def _core_get_configured_token_limits(self, model_name: str) -> "tuple[int | None, int | None]":
+ """
+ Return (max_input_tokens, max_output_tokens) configured or discovered for a concrete
+ deployment of model_name, via O(1) index lookup.
+
+ Returns (None, None) for wildcard-expanded or unknown names, and treats a
+ malformed configured value as absent rather than failing the caller.
+
+ Deliberately reads one deployment rather than aggregating the group the way
+ get_model_listing_info does: its caller truncates an embedding input to this
+ value, so the widest window in a mixed group would be the wrong answer there.
+ """
+ deployment: Final = self._core_get_deployment_by_model_group_name(model_group_name=model_name)
+ if deployment is None:
+ return (None, None)
+
+ model_info: Final = MappingProxyType(
+ {
+ **self._core_get_discovered_model_info(deployment.model_info.id),
+ **deployment.model_info.model_dump(exclude_none=True),
+ }
+ )
+ return (
+ coerce_token_limit(model_info.get("max_input_tokens")),
+ coerce_token_limit(model_info.get("max_output_tokens")),
+ )
+
+ def _core_get_configured_mode(self, model_name: str) -> "str | None":
+ """Return the mode explicitly configured for a concrete deployment."""
+ deployment: Final = self._core_get_deployment_by_model_group_name(model_group_name=model_name)
+ if deployment is None:
+ return None
+
+ mode: Final = deployment.model_info.get("mode")
+ if isinstance(mode, str) and mode.strip():
+ return mode
+ return None
+
+ def _core_get_configured_display_name(self, model_name: str) -> "str | None":
+ """
+ Return the display_name explicitly configured in a concrete deployment's
+ model_info for model_name, via O(1) index lookup.
+
+ Returns None for wildcard-expanded or unknown names, and treats a
+ non-string or empty configured value as absent rather than failing the
+ listing. Like get_configured_token_limits, this never triggers pattern
+ matching or deep copies, so it is safe to call per listed model on the
+ /v1/models hot path.
+ """
+ deployment: Final = self._core_get_deployment_by_model_group_name(model_group_name=model_name)
+ if deployment is None:
+ return None
+
+ display_name: Final = deployment.model_info.get("display_name")
+ if isinstance(display_name, str) and display_name.strip():
+ return display_name
+ return None
+
+ def _core_routable_model_group(self, model_name: str) -> str:
+ """
+ The model group a request to model_name routes to: its target when
+ model_name is a `model_group_alias`, else model_name itself.
+ """
+ target: Final = self._core_get_model_from_alias(model_name)
+ return target if target is not None else model_name
+
+ def _routable_deployments(self, model_name: str, team_id: str | None) -> tuple[DeploymentTypedDict, ...]:
+ """
+ The deployments a request from team_id to model_name can route to, in
+ model_list order and via the same O(1) index lookup as
+ get_configured_display_name, selected the way routing selects them: a
+ `model_group_alias` reads its target's deployments, another team's
+ deployment of the name is left out, a caller with no team reads the
+ deployments no team owns (every deployment of the name when a team owns
+ each one, which is what an admin's request routes to), and one an admin
+ paused via `LiteLLM_ProxyModelTable.blocked` is left out.
+
+ Returns an empty tuple for wildcard-expanded or unknown names.
+ """
+ named: Final = self._core_get_all_deployments(
+ model_name=self._core_routable_model_group(model_name), team_id=team_id
+ )
+ usable: Final = tuple(
+ deployment for deployment in named if self._core_deployment_usable_by_team(deployment, team_id)
+ )
+ return tuple(
+ deployment
+ for deployment in (usable or named)
+ if _configured_model_info(deployment).get("blocked") is not True
+ )
+
+ def _core_get_configured_service_tiers(self, model_name: str, team_id: str | None = None) -> tuple[object, ...]:
+ """
+ Return the service_tiers value each deployment a request from team_id
+ to model_name can route to (see _routable_deployments) configures in
+ its model_info, unvalidated and in model_list order, None for a
+ deployment that sets none; the caller validates the shape it expects
+ and decides how the deployments combine.
+
+ Returns an empty tuple for wildcard-expanded or unknown names.
+ """
+ return tuple(
+ _configured_model_info(deployment).get("service_tiers")
+ for deployment in self._routable_deployments(model_name, team_id)
+ )
+
+ def _core_get_search_tool_support(self, model_name: str, team_id: str | None = None) -> tuple[bool | None, ...]:
+ """
+ Whether each deployment a request from team_id to model_name can route
+ to (see _routable_deployments) answers Codex's client `tool_search`:
+ its model_info.supports_search_tool when that is a bool, else True when
+ its route builds an Anthropic Messages request, else None (unknown).
+ """
+ return tuple(_search_tool_support(deployment) for deployment in self._routable_deployments(model_name, team_id))
+
+ def _core_get_routable_upstream_model(self, model_name: str, team_id: str | None = None) -> str | None:
+ """
+ Return the `litellm_params.model` of the first deployment a request
+ from team_id to model_name can route to (see _routable_deployments).
+
+ Returns None for wildcard-expanded or unknown names, and when the team
+ can route to no deployment of the name.
+ """
+ deployment: Final = next(iter(self._routable_deployments(model_name, team_id)), None)
+ return deployment["litellm_params"].get("model") if deployment is not None else None
+
+ def _core_get_credential_deployment(self, model_id: str, team_id: str | None = None) -> Deployment | None:
+ """
+ The deployment a passthrough endpoint (files, batches, etc.) resolves for a
+ model id or model name: by deployment id first, then by model_name, then by
+ the team's exact public model name, then by wildcard pattern (team wildcards
+ before global ones, so a global "openai/*" never shadows the team's own
+ entry). Name and wildcard lookups never resolve another team's deployment.
+
+ Returns None when nothing matches or the match is paused via
+ `LiteLLM_ProxyModelTable.blocked`, so callers cannot bypass an admin pause
+ by resolving the deployment directly.
+ """
+ deployment: Final = (
+ self._core_get_deployment(model_id=model_id)
+ or self._get_model_group_deployment_usable_by_team(model_group_name=model_id, team_id=team_id)
+ or self._get_team_public_name_deployment(model_id=model_id, team_id=team_id)
+ or self._get_wildcard_deployment_usable_by_team(model_id=model_id, team_id=team_id)
+ )
+ if deployment is None or self._core_is_deployment_blocked(deployment):
+ return None
+ return deployment
+
+ def _get_team_public_name_deployment(self, model_id: str, team_id: str | None) -> Deployment | None:
+ if team_id is None:
+ return None
+ with self._deployment_index_lock:
+ team_indices: Final = self._core_team_model_to_deployment_indices.get((team_id, model_id))
+ team_model: Final = self._core_model_list[team_indices[0]] if team_indices else None
+ if team_model is None:
+ return None
+ return Deployment(**team_model) if isinstance(team_model, dict) else team_model
+
+ def _get_wildcard_deployment_usable_by_team(self, model_id: str, team_id: str | None) -> Deployment | None:
+ team_pattern_router: Final = self._core_team_pattern_routers.get(team_id) if team_id is not None else None
+ team_wildcard_models: Final = team_pattern_router.route(model_id) if team_pattern_router else None
+ global_wildcard_models: Final = tuple(
+ wildcard_model
+ for wildcard_model in (self._core_pattern_router.route(model_id) or ())
+ if self._core_deployment_usable_by_team(wildcard_model, team_id)
+ )
+ potential_wildcard_models: Final = team_wildcard_models or global_wildcard_models
+ if not potential_wildcard_models:
+ return None
+ wildcard_deployment: Final = potential_wildcard_models[0]
+ if isinstance(wildcard_deployment, dict):
+ return Deployment(**wildcard_deployment)
+ if isinstance(wildcard_deployment, Deployment):
+ return wildcard_deployment
+ return None
+
+ def _core_get_deployment_credentials_with_provider(
+ self, model_id: str, team_id: str | None = None
+ ) -> dict[str, Any] | None:
+ """
+ Get API credentials and provider info from a model name in model_list.
+ Useful for passthrough endpoints (files, batches, etc.) that need credentials.
+
+ Resolves the deployment with `get_credential_deployment` (by deployment id,
+ then model_name, team public model name, and wildcard pattern).
+
+ Args:
+ model_id: Model ID or model name from model_list (e.g., "gpt-4o-litellm")
+ team_id: Optional team id of the caller. When set, team-scoped
+ deployments (indexed by team public model name, including team
+ wildcard models like "openai/*") are also considered. Name and
+ wildcard lookups never resolve a deployment owned by a
+ different team, so shared model names can't leak another
+ team's credentials.
+
+ Returns:
+ Dictionary containing api_key, api_base, custom_llm_provider, etc.
+ Returns None if model not found, or if the resolved deployment is
+ paused via `LiteLLM_ProxyModelTable.blocked` (so passthrough callers
+ cannot bypass an admin pause by resolving credentials directly).
+
+ Example:
+ credentials = router.get_deployment_credentials_with_provider("gpt-4o-litellm")
+ # Returns: {"api_key": "sk-...", "custom_llm_provider": "openai", "model": "gpt-4o", ...}
+ """
+ deployment: Final = self._core_get_credential_deployment(model_id=model_id, team_id=team_id)
+ if deployment is None:
+ return None
+
+ # Get basic credentials
+ credentials: Final = CredentialLiteLLMParams.model_validate(
+ deployment.litellm_params.model_dump(exclude_none=True)
+ ).model_dump(exclude_none=True)
+
+ # Resolve litellm_credential_name to actual credentials
+ if deployment.litellm_params.litellm_credential_name is not None:
+ credential_values: Final = CredentialAccessor.get_credential_values(
+ deployment.litellm_params.litellm_credential_name
+ )
+ if not credential_values:
+ verbose_router_logger.warning(
+ "Credential '%s' not found in credential_list", deployment.litellm_params.litellm_credential_name
+ )
+ credentials.update(credential_values)
+ # Remove the credential name since we've resolved it
+ credentials.pop("litellm_credential_name", None)
+
+ credentials["model"] = deployment.litellm_params.model
+
+ # Add custom_llm_provider
+ if deployment.litellm_params.custom_llm_provider:
+ credentials["custom_llm_provider"] = deployment.litellm_params.custom_llm_provider
+ elif "/" in deployment.litellm_params.model:
+ # Extract provider from "provider/model" format
+ credentials["custom_llm_provider"] = deployment.litellm_params.model.split("/")[0]
+ else:
+ credentials["custom_llm_provider"] = "openai" # default
+
+ return credentials
+
+ @overload
+ def _core_get_router_model_info(
+ self,
+ deployment: Union[dict, "Deployment"],
+ received_model_name: str,
+ id: None = None,
+ ) -> ModelMapInfo:
+ pass
+
+ @overload
+ def _core_get_router_model_info(self, deployment: None, received_model_name: str, id: str) -> ModelMapInfo:
+ pass
+
+ def _core_get_router_model_info(
+ self,
+ deployment: Union[dict, "Deployment"] | None,
+ received_model_name: str,
+ id: str | None = None,
+ ) -> ModelMapInfo:
+ """
+ For a given model id, return the model info (max tokens, input cost, output cost, etc.).
+
+ Augment litellm info with additional params set in `model_info`.
+
+ For azure models, ignore the `model:`. Only set max tokens, cost values if base_model is set.
+ For every other provider, a base_model naming the same model as `model:` can only widen its context window.
+
+ Returns
+ - ModelInfo - If found -> typed dict with max tokens, input cost, etc.
+
+ Raises:
+ - ValueError -> If model is not mapped yet
+ """
+ if id is not None:
+ _deployment: Final = self._core_get_deployment(model_id=id)
+ if _deployment is not None:
+ deployment = _deployment
+
+ if deployment is None:
+ raise ValueError("Deployment not found")
+
+ ## GET BASE MODEL
+ base_model = (deployment.get("model_info") or {}).get("base_model", None)
+ if base_model is None:
+ base_model = (deployment.get("litellm_params") or {}).get("base_model", None)
+
+ model = base_model
+
+ ## GET PROVIDER - reuse LiteLLM_Params if already constructed
+ litellm_params_data: Final = deployment.get("litellm_params")
+ litellm_params: LiteLLM_Params
+ if isinstance(litellm_params_data, LiteLLM_Params):
+ litellm_params = litellm_params_data
+ elif isinstance(litellm_params_data, dict) and "model" in litellm_params_data:
+ litellm_params = LiteLLM_Params.model_validate(litellm_params_data)
+ else:
+ raise ValueError(
+ f"Deployment missing valid litellm_params. "
+ f"Got: {type(litellm_params_data).__name__}, "
+ f"deployment_id: {(deployment.get('model_info') or {}).get('id', 'unknown')}"
+ )
+ _model, custom_llm_provider, _, _ = self._host.invoke("get_llm_provider")(
+ model=litellm_params.model,
+ litellm_params=litellm_params,
+ )
+
+ ## SET MODEL TO 'model=' - if base_model is None + not azure
+ if custom_llm_provider == "azure" and base_model is None:
+ # Router init auto-registers every deployment name into
+ # litellm.model_cost as a zeroed stub, so membership alone can't
+ # tell a resolvable name apart; require usable limits/costs.
+ _azure_fallback_key = _model if _model.startswith("azure/") else f"azure/{_model}"
+ _fallback_entry = self._host.dynamic().model_cost.get(_azure_fallback_key)
+ _fallback_resolves = isinstance(_fallback_entry, Mapping) and (
+ (_fallback_entry.get("max_input_tokens") or 0) > 0
+ or (_fallback_entry.get("input_cost_per_token") or 0) > 0
+ )
+ if _fallback_resolves:
+ verbose_router_logger.debug(
+ "Azure deployment '%s' has no base_model set; using '%s' from the model cost map for max tokens, cost tracking, etc.",
+ _model,
+ _azure_fallback_key,
+ )
+ else:
+ verbose_router_logger.error(
+ "Could not identify azure model '%s'. Set azure 'base_model' for accurate max tokens, cost tracking, etc.- https://docs.litellm.ai/docs/proxy/cost_tracking#spend-tracking-for-azure-openai-models",
+ _model,
+ )
+ elif custom_llm_provider != "azure":
+ model = _model
+
+ if "*" in model: # only call pattern_router for wildcard models
+ potential_models: Final = self._core_pattern_router.route(received_model_name)
+ if potential_models is not None:
+ for potential_model in potential_models:
+ try:
+ if (potential_model.get("model_info") or {}).get("id") == (
+ deployment.get("model_info") or {}
+ ).get("id"):
+ model = (potential_model.get("litellm_params") or {}).get("model")
+ break
+ except Exception:
+ pass
+
+ ## GET LITELLM MODEL INFO - raises exception, if model is not mapped
+ if model is None:
+ # Handle case where base_model is None (e.g., Azure models without base_model set)
+ # Use the original model from litellm_params
+ model = _model
+
+ if not model.startswith(f"{custom_llm_provider}/"):
+ model_info_name = f"{custom_llm_provider}/{model}"
+ else:
+ model_info_name = model
+
+ model_info: Final = self._host.invoke("get_model_info")(model=model_info_name)
+ if model_info is None:
+ return model_info
+
+ ## CHECK USER SET MODEL INFO
+ raw_user_model_info: Final = deployment.get("model_info")
+ user_model_info: Final = _MODEL_INFO_ADAPTER.validate_python(
+ raw_user_model_info.model_dump(exclude_none=True)
+ if isinstance(raw_user_model_info, BaseModel)
+ else raw_user_model_info or {}
+ )
+
+ # get_model_info() hands back an lru_cache'd dict, so merge into a copy; unset
+ # values are skipped or Deployment's None pricing defaults would erase the map's
+ merged_model_info: Final[ModelMapInfo] = {
+ **copy.deepcopy(model_info),
+ **_base_model_widened_window(
+ model_info,
+ _declared_base_model(user_model_info) or _declared_base_model(litellm_params.model_extra or {}),
+ custom_llm_provider,
+ ),
+ **self._core_get_discovered_model_info((deployment.get("model_info") or {}).get("id")),
+ **MappingProxyType({key: value for key, value in user_model_info.items() if value is not None}),
+ }
+
+ return merged_model_info
+
+ def _core_get_model_info(self, id: str) -> dict | None:
+ """
+ For a given model id, return the model info
+
+ Returns
+ - dict: the model in list with 'model_name', 'litellm_params', Optional['model_info']
+ - None: could not find deployment in list
+
+ Optimized with O(1) index lookup instead of O(n) linear scan.
+ """
+ # O(1) lookup via model_id_to_deployment_index_map
+ with self._deployment_index_lock:
+ idx: Final = self.model_id_to_deployment_index_map.get(id)
+ return None if idx is None else self._core_model_list[idx]
+
+ def _core_get_model_group(self, id: str) -> list | None:
+ """
+ Return list of all models in the same model group as that model id
+ """
+
+ model_info: Final = self._core_get_model_info(id=id)
+ if model_info is None:
+ return None
+
+ model_name: Final = model_info["model_name"]
+ return self._core_get_model_list(model_name=model_name)
+
+ def _core_get_deployment_model_info(self, model_id: str, model_name: str) -> ModelInfo | None:
+ """
+ For a given model id, return the model info
+
+ 1. Check if model_id is in model info
+ 2. If not, check if litellm model name is in model info
+ 3. If not, return None
+ """
+ from litellm.utils import cost_map_omits_token_price, update_dictionary
+
+ model_info: ModelInfo | None = None
+ custom_model_info: dict | None = None
+ litellm_model_name_model_info: ModelInfo | None = None
+ base_model_key: str | None = None
+
+ try:
+ if model_id in self._host.dynamic().model_cost:
+ cost_entry: Final = self._host.dynamic().model_cost.get(model_id) or MappingProxyType({})
+ if not isinstance(cost_entry, Mapping):
+ raise TypeError(f"model_cost[{model_id!r}] is not a mapping")
+ custom_model_info = {
+ **copy.deepcopy(cost_entry),
+ **self._core_get_discovered_model_info(model_id),
+ }
+ except Exception:
+ pass
+
+ try:
+ litellm_model_name_model_info = self._host.invoke("get_model_info")(model=model_name)
+ except Exception:
+ pass
+
+ ## check for base model
+ try:
+ if custom_model_info is not None:
+ base_model: Final = custom_model_info.get("base_model", None)
+ if base_model is not None:
+ ## update litellm model info with base model info
+ base_model_info: Final = copy.deepcopy(self._host.invoke("get_model_info")(model=base_model))
+ if base_model_info is not None:
+ base_model_key = base_model_info.get("key")
+ # Base model provides defaults, custom model info overrides
+ custom_model_info = update_dictionary(
+ cast(dict, base_model_info),
+ custom_model_info,
+ )
+ except Exception:
+ pass
+
+ # Three mutually exclusive scenarios for the model's metadata:
+ if custom_model_info is not None and litellm_model_name_model_info is not None:
+ # (1) It has both custom model_info set and exists in the built-in map
+ # merge with custom overriding built-in
+ model_info = cast(
+ ModelInfo,
+ update_dictionary(
+ copy.deepcopy(cast(dict, litellm_model_name_model_info)),
+ custom_model_info,
+ ),
+ )
+ elif litellm_model_name_model_info is not None:
+ # (2) Built-in only — no custom pricing to merge
+ model_info = copy.deepcopy(litellm_model_name_model_info)
+ elif custom_model_info is not None:
+ # (3) Custom only — model not in built-in cost map yet
+ # custom_model_info already includes base_model defaults at this point, if applicable
+ model_info = cast(ModelInfo, custom_model_info)
+
+ if model_info is None:
+ return None
+ builtin_key: Final = (
+ litellm_model_name_model_info.get("key") if litellm_model_name_model_info is not None else None
+ )
+ if cost_map_omits_token_price(model_id, builtin_key, base_model_key):
+ return cast( # cast-ok: TypedDict spread with overridden keys loses its type
+ ModelInfo, {**model_info, "input_cost_per_token": None, "output_cost_per_token": None}
+ )
+ return model_info
+
+ @staticmethod
+ def _model_group_llm_provider(litellm_params: LiteLLM_Params) -> tuple[str, str]:
+ try:
+ return resolve_model_and_provider_for_metadata(
+ model=litellm_params.model,
+ custom_llm_provider=litellm_params.custom_llm_provider,
+ )
+ except litellm_exceptions.BadRequestError as e:
+ verbose_router_logger.error("litellm.router.py::get_model_group_info() - %s", e)
+ return "", ""
+
+ def _set_model_group_info(
+ self, model_group: str, user_facing_model_group_name: str, team_id: str | None = None
+ ) -> ModelGroupInfo | None:
+ """
+ For a given model group name, return the combined model info
+
+ Returns:
+ - ModelGroupInfo if able to construct a model group
+ - None if error constructing model group info
+ """
+ model_group_info: ModelGroupInfo | None = None
+
+ total_tpm: int | None = None
+ total_rpm: int | None = None
+ total_itpm: int | None = None
+ total_otpm: int | None = None
+ configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None
+ reasoning_efforts_initialized = False
+ reasoning_efforts_unknown = False
+ serves_team_deployments: Final = team_id is not None and bool(
+ self._team_deployments_named(team_id, model_group)
+ )
+ model_list: Final = self._core_get_model_list_of_routed_group(model_group, team_id)
+ model_group_description: Final[str | None] = next(
+ (d for d in (_configured_model_info(m).get("description") for m in model_list) if isinstance(d, str)),
+ None,
+ )
+ for model in model_list:
+ is_match = False
+ if (
+ serves_team_deployments
+ or "model_name" in model
+ and model["model_name"] == model_group
+ or "model_name" in model
+ and self._core_pattern_router.route(model_group) is not None
+ ): # exact match
+ is_match = True
+
+ if not is_match:
+ continue
+ # model in model group found #
+ litellm_params = LiteLLM_Params(**model["litellm_params"])
+ # get configurable clientside auth params
+ configurable_clientside_auth_params = litellm_params.configurable_clientside_auth_params
+
+ # Cache nested dict access to avoid repeated temporary dict allocations
+ model_litellm_params = model.get("litellm_params", {})
+ model_info_dict = model.get("model_info", {})
+
+ # get model tpm
+ _deployment_tpm: int | None = None
+ if _deployment_tpm is None:
+ _deployment_tpm = model.get("tpm", None)
+ if _deployment_tpm is None:
+ _deployment_tpm = model_litellm_params.get("tpm", None)
+ if _deployment_tpm is None:
+ _deployment_tpm = model_info_dict.get("tpm", None)
+
+ # get model rpm
+ _deployment_rpm: int | None = None
+ if _deployment_rpm is None:
+ _deployment_rpm = model.get("rpm", None)
+ if _deployment_rpm is None:
+ _deployment_rpm = model_litellm_params.get("rpm", None)
+ if _deployment_rpm is None:
+ _deployment_rpm = model_info_dict.get("rpm", None)
+
+ _deployment_itpm: int | None = model.get("itpm")
+ if _deployment_itpm is None:
+ _deployment_itpm = model_litellm_params.get("itpm", None)
+ if _deployment_itpm is None:
+ _deployment_itpm = model_info_dict.get("itpm", None)
+
+ _deployment_otpm: int | None = model.get("otpm")
+ if _deployment_otpm is None:
+ _deployment_otpm = model_litellm_params.get("otpm", None)
+ if _deployment_otpm is None:
+ _deployment_otpm = model_info_dict.get("otpm", None)
+
+ # get model info
+ try:
+ model_id = model_info_dict.get("id", None)
+ if model_id is not None:
+ model_info = self._core_get_deployment_model_info(
+ model_id=model_id, model_name=litellm_params.model
+ )
+ else:
+ model_info = None
+ except Exception:
+ model_info = None
+
+ deployment_is_mapped = deployment_is_catalog_mapped(model_info, model_info_dict)
+
+ # get llm provider
+ litellm_model, llm_provider = self._model_group_llm_provider(litellm_params)
+
+ if model_info is None:
+ supported_openai_params = self._host.invoke("get_supported_openai_params")(
+ model=litellm_model, custom_llm_provider=llm_provider
+ )
+ if supported_openai_params is None:
+ supported_openai_params = []
+
+ # Get mode from database model_info if available, otherwise default to "chat"
+ db_model_info = model.get("model_info", {})
+ mode = db_model_info.get("mode", "chat")
+ input_cost_per_token = _cost_value_as_float(db_model_info.get("input_cost_per_token"))
+ output_cost_per_token = _cost_value_as_float(db_model_info.get("output_cost_per_token"))
+
+ model_info = ModelMapInfo(
+ key=model_group,
+ max_tokens=None,
+ max_input_tokens=None,
+ max_output_tokens=None,
+ input_cost_per_token=input_cost_per_token,
+ output_cost_per_token=output_cost_per_token,
+ litellm_provider=llm_provider,
+ mode=mode,
+ supported_openai_params=supported_openai_params,
+ supports_system_messages=None,
+ )
+
+ if model_group_info is None:
+ model_group_info = ModelGroupInfo(
+ **{
+ "model_group": user_facing_model_group_name,
+ "providers": [llm_provider],
+ "description": model_group_description,
+ **model_info,
+ "supports_fast_mode": True,
+ "supported_reasoning_efforts": None,
+ "supported_endpoints": None,
+ }
+ )
+ else:
+ # if max_input_tokens > curr
+ # if max_output_tokens > curr
+ # if input_cost_per_token > curr
+ # if output_cost_per_token > curr
+ # supports_parallel_function_calling == True
+ # supports_vision == True
+ # supports_function_calling == True
+ if llm_provider not in model_group_info.providers:
+ model_group_info.providers.append(llm_provider)
+ if (
+ model_info.get("max_input_tokens", None) is not None
+ and model_info["max_input_tokens"] is not None
+ and (
+ model_group_info.max_input_tokens is None
+ or model_info["max_input_tokens"] > model_group_info.max_input_tokens
+ )
+ ):
+ model_group_info.max_input_tokens = model_info["max_input_tokens"]
+ if (
+ model_info.get("max_output_tokens", None) is not None
+ and model_info["max_output_tokens"] is not None
+ and (
+ model_group_info.max_output_tokens is None
+ or model_info["max_output_tokens"] > model_group_info.max_output_tokens
+ )
+ ):
+ model_group_info.max_output_tokens = model_info["max_output_tokens"]
+ _input_cost_per_token = _cost_value_as_float(model_info.get("input_cost_per_token"))
+ if _input_cost_per_token is not None and (
+ model_group_info.input_cost_per_token is None
+ or _input_cost_per_token > (model_group_info.input_cost_per_token or 0.0)
+ ):
+ model_group_info.input_cost_per_token = _input_cost_per_token
+ _output_cost_per_token = _cost_value_as_float(model_info.get("output_cost_per_token"))
+ if _output_cost_per_token is not None and (
+ model_group_info.output_cost_per_token is None
+ or _output_cost_per_token > (model_group_info.output_cost_per_token or 0.0)
+ ):
+ model_group_info.output_cost_per_token = _output_cost_per_token
+ if (
+ model_info.get("supports_parallel_function_calling", None) is not None
+ and model_info["supports_parallel_function_calling"] is True
+ ):
+ model_group_info.supports_parallel_function_calling = True
+ if model_info.get("supports_vision", None) is not None and model_info["supports_vision"] is True:
+ model_group_info.supports_vision = True
+ if (
+ model_info.get("supports_function_calling", None) is not None
+ and model_info["supports_function_calling"] is True
+ ):
+ model_group_info.supports_function_calling = True
+ if (
+ model_info.get("supports_web_search", None) is not None
+ and model_info["supports_web_search"] is True
+ ):
+ model_group_info.supports_web_search = True
+ if (
+ model_info.get("supports_url_context", None) is not None
+ and model_info["supports_url_context"] is True
+ ):
+ model_group_info.supports_url_context = True
+
+ if model_info.get("supports_reasoning", None) is not None and model_info["supports_reasoning"] is True:
+ model_group_info.supports_reasoning = True
+ if (
+ model_info.get("supported_openai_params", None) is not None
+ and model_info["supported_openai_params"] is not None
+ ):
+ model_group_info.supported_openai_params = model_info["supported_openai_params"]
+ if model_info.get("tpm", None) is not None and _deployment_tpm is None:
+ _deployment_tpm = model_info.get("tpm")
+ if model_info.get("rpm", None) is not None and _deployment_rpm is None:
+ _deployment_rpm = model_info.get("rpm")
+
+ model_group_info.supported_endpoints = (
+ tuple(
+ dict.fromkeys(
+ (
+ *(model_group_info.supported_endpoints or ()),
+ *(
+ deployment_supported_endpoints(
+ model_info.get("supported_endpoints"), model, llm_provider
+ )
+ if team_may_use_deployment(_configured_model_info(model).get("team_id"), team_id)
+ else ()
+ ),
+ )
+ )
+ )
+ or None
+ )
+ model_group_info.supports_fast_mode = model_group_info.supports_fast_mode and (
+ AnthropicModelInfo.supports_fast_mode(litellm_model, llm_provider)
+ )
+ deployment_reasoning_efforts = resolve_supported_reasoning_efforts(
+ model_info, deployment_is_mapped=deployment_is_mapped
+ )
+ if deployment_reasoning_efforts is None:
+ reasoning_efforts_unknown = True
+ model_group_info.supported_reasoning_efforts = None
+ elif not reasoning_efforts_initialized:
+ reasoning_efforts_initialized = True
+ if not reasoning_efforts_unknown:
+ model_group_info.supported_reasoning_efforts = deployment_reasoning_efforts
+ elif not reasoning_efforts_unknown:
+ model_group_info.supported_reasoning_efforts = intersect_supported_reasoning_efforts(
+ model_group_info.supported_reasoning_efforts,
+ deployment_reasoning_efforts,
+ )
+
+ if _deployment_tpm is not None:
+ if total_tpm is None:
+ total_tpm = 0
+ total_tpm += _deployment_tpm
+
+ if _deployment_rpm is not None:
+ if total_rpm is None:
+ total_rpm = 0
+ total_rpm += _deployment_rpm
+
+ if _deployment_itpm is not None:
+ if total_itpm is None:
+ total_itpm = 0
+ total_itpm += _deployment_itpm
+
+ if _deployment_otpm is not None:
+ if total_otpm is None:
+ total_otpm = 0
+ total_otpm += _deployment_otpm
+ if model_group_info is not None:
+ ## UPDATE WITH TOTAL TPM/RPM FOR MODEL GROUP
+ if total_tpm is not None:
+ model_group_info.tpm = total_tpm
+
+ if total_rpm is not None:
+ model_group_info.rpm = total_rpm
+
+ if total_itpm is not None:
+ model_group_info.itpm = total_itpm
+
+ if total_otpm is not None:
+ model_group_info.otpm = total_otpm
+
+ ## UPDATE WITH CONFIGURABLE CLIENTSIDE AUTH PARAMS FOR MODEL GROUP
+ if configurable_clientside_auth_params is not None:
+ model_group_info.configurable_clientside_auth_params = configurable_clientside_auth_params
+
+ return model_group_info
+
+ def _core_get_model_group_info(
+ self, model_group: str, *, include_hidden: bool = False, team_id: str | None = None
+ ) -> ModelGroupInfo | None:
+ """
+ For a given model group name, return the combined model info
+
+ Returns:
+ - ModelGroupInfo if able to construct a model group
+ - None if error constructing model group info or hidden model group (unless include_hidden)
+ """
+ ## Check if model group alias
+ if model_group in self._core_model_group_alias:
+ item: Final = self._core_model_group_alias[model_group]
+ if isinstance(item, str):
+ _router_model_group = item
+ elif isinstance(item, dict):
+ if item["hidden"] is True and not include_hidden:
+ return None
+ else:
+ _router_model_group = item["model"]
+ else:
+ return None
+
+ return self._set_model_group_info(
+ model_group=_router_model_group,
+ user_facing_model_group_name=model_group,
+ team_id=team_id,
+ )
+
+ ## Check if actual model
+ return self._set_model_group_info(
+ model_group=model_group, user_facing_model_group_name=model_group, team_id=team_id
+ )
+
+ async def _core_get_model_group_usage(self, model_group: str) -> tuple[int | None, int | None]:
+ """
+ Returns current tpm/rpm usage for model group
+
+ Parameters:
+ - model_group: str - the received model name from the user (can be a wildcard route).
+
+ Returns:
+ - usage: Tuple[tpm, rpm]
+ """
+ dt: Final = get_utc_datetime()
+ current_minute: Final = dt.strftime("%H-%M") # use the same timezone regardless of system clock
+ tpm_keys: Final[list[str]] = []
+ rpm_keys: Final[list[str]] = []
+
+ model_list: Final = self._core_get_model_list(model_name=model_group)
+ if model_list is None: # no matching deployments
+ return None, None
+
+ for model in model_list:
+ id: str | None = model.get("model_info", {}).get("id")
+ litellm_model: str | None = model["litellm_params"].get(
+ "model"
+ ) # USE THE MODEL SENT TO litellm.completion() - consistent with how global_router cache is written.
+ if id is None or litellm_model is None:
+ continue
+ tpm_keys.append(
+ RouterCacheEnum.TPM.value.format(
+ id=id,
+ model=litellm_model,
+ current_minute=current_minute,
+ )
+ )
+ rpm_keys.append(
+ RouterCacheEnum.RPM.value.format(
+ id=id,
+ model=litellm_model,
+ current_minute=current_minute,
+ )
+ )
+ combined_tpm_rpm_keys: Final = tpm_keys + rpm_keys
+
+ combined_tpm_rpm_values: Final = await self._core_cache.async_batch_get_cache(keys=combined_tpm_rpm_keys)
+ if combined_tpm_rpm_values is None:
+ return None, None
+
+ tpm_usage_list: Final[list | None] = combined_tpm_rpm_values[: len(tpm_keys)]
+ rpm_usage_list: Final[list | None] = combined_tpm_rpm_values[len(tpm_keys) :]
+
+ ## TPM
+ tpm_usage: int | None = None
+ if tpm_usage_list is not None:
+ for t in tpm_usage_list:
+ if isinstance(t, int):
+ if tpm_usage is None:
+ tpm_usage = 0
+ tpm_usage += t
+ ## RPM
+ rpm_usage: int | None = None
+ if rpm_usage_list is not None:
+ for t in rpm_usage_list:
+ if isinstance(t, int):
+ if rpm_usage is None:
+ rpm_usage = 0
+ rpm_usage += t
+ return tpm_usage, rpm_usage
+
+ async def get_model_group_io_token_usage(self, model_group: str) -> tuple[int | None, int | None]:
+ """
+ Returns current ITPM/OTPM usage for a model group (sum across deployments).
+ """
+ dt: Final = get_utc_datetime()
+ current_minute: Final = dt.strftime("%H-%M")
+ itpm_keys: Final[list[str]] = []
+ otpm_keys: Final[list[str]] = []
+
+ model_list: Final = self._core_get_model_list(model_name=model_group)
+ if model_list is None:
+ return None, None
+
+ for model in model_list:
+ model_id: str | None = model.get("model_info", {}).get("id")
+ litellm_model: str | None = model["litellm_params"].get("model")
+ if model_id is None or litellm_model is None:
+ continue
+ itpm_keys.append(
+ RouterCacheEnum.ITPM.value.format(
+ id=model_id,
+ model=litellm_model,
+ current_minute=current_minute,
+ )
+ )
+ otpm_keys.append(
+ RouterCacheEnum.OTPM.value.format(
+ id=model_id,
+ model=litellm_model,
+ current_minute=current_minute,
+ )
+ )
+
+ combined_values: Final = await self._core_cache.async_batch_get_cache(keys=itpm_keys + otpm_keys)
+ if combined_values is None:
+ return None, None
+
+ itpm_values: Final = combined_values[: len(itpm_keys)]
+ otpm_values: Final = combined_values[len(itpm_keys) :]
+
+ total_itpm: int | None = None
+ for value in itpm_values:
+ if isinstance(value, int):
+ total_itpm = (total_itpm or 0) + value
+
+ total_otpm: int | None = None
+ for value in otpm_values:
+ if isinstance(value, int):
+ total_otpm = (total_otpm or 0) + value
+
+ return total_itpm, total_otpm
+
+ @lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)
+ def _cached_get_model_group_info(self, model_group: str) -> ModelGroupInfo | None:
+ """
+ Cached version of get_model_group_info, uses @lru_cache wrapper
+
+ This is a speed optimization, since set_response_headers makes a call to get_model_group_info on every request
+ """
+ return self._core_get_model_group_info(model_group)
+
+ def _core_cached_model_group_info(self, model_group: str) -> ModelGroupInfo | None:
+ return self._cached_get_model_group_info(model_group)
+
+ async def _core_get_remaining_model_group_usage(self, model_group: str) -> dict[str, int]:
+ model_group_info: Final = self._cached_get_model_group_info(model_group)
+
+ returned_dict: Final[dict[str, int]] = {}
+
+ # ITPM/OTPM groups emit input/output token headers, but they may also set
+ # tpm/rpm, so build both sets rather than returning early - clients and
+ # prometheus gauges that read the standard headers still get data.
+ if model_group_info is not None and (model_group_info.itpm is not None or model_group_info.otpm is not None):
+ current_itpm, current_otpm = await self.get_model_group_io_token_usage(model_group)
+ returned_dict.update(
+ build_io_token_rate_limit_headers(
+ itpm_limit=model_group_info.itpm,
+ otpm_limit=model_group_info.otpm,
+ current_itpm=current_itpm,
+ current_otpm=current_otpm,
+ )
+ )
+
+ tpm_limit: Final = model_group_info.tpm if model_group_info is not None else None
+ rpm_limit: Final = model_group_info.rpm if model_group_info is not None else None
+
+ if tpm_limit is None and rpm_limit is None:
+ return returned_dict
+
+ current_tpm, current_rpm = await self._core_get_model_group_usage(model_group)
+
+ if tpm_limit is not None:
+ returned_dict["x-ratelimit-remaining-tokens"] = tpm_limit - (current_tpm or 0)
+ returned_dict["x-ratelimit-limit-tokens"] = tpm_limit
+ if rpm_limit is not None:
+ returned_dict["x-ratelimit-remaining-requests"] = rpm_limit - (current_rpm or 0)
+ returned_dict["x-ratelimit-limit-requests"] = rpm_limit
+
+ return returned_dict
+
+ async def set_response_headers(
+ self,
+ response: object,
+ model_group: str | None = None,
+ request_kwargs: dict[str, object] | None = None,
+ ) -> Any:
+ """
+ Add the most accurate rate limit headers for a given model response.
+
+ ## TODO: add model group rate limit headers
+ # - if healthy_deployments > 1, return model group rate limit headers
+ # - else return the model's rate limit headers
+ """
+ response = prepare_response_for_header_attachment(response)
+ if response is None:
+ return response
+
+ additional_headers: Final = ensure_response_additional_headers(response)
+ apply_response_model_id(
+ response, find_deployment_metadata(request_kwargs) if request_kwargs is not None else None
+ )
+ additional_headers["x-litellm-model-group"] = model_group
+ apply_quality_router_decision_headers(additional_headers, request_kwargs)
+ additional_headers.update(complexity_router_decision_headers(request_kwargs))
+
+ if model_group is not None:
+ remaining_usage: Final = await self._core_get_remaining_model_group_usage(model_group)
+ apply_remaining_usage_headers(additional_headers, remaining_usage)
+ return response
+
+ def _build_model_name_index(self, model_list: list) -> None:
+ """
+ Build model_name -> deployment indices mapping for O(1) lookups.
+
+ This index allows us to find all deployments for a given model_name in O(1) time
+ instead of O(n) linear scan through the entire model_list.
+ """
+ self._core_model_name_to_deployment_indices.clear()
+ self._core_team_model_to_deployment_indices.clear()
+ self._core_team_public_model_names = frozenset()
+
+ for idx, model in enumerate(model_list):
+ model_name = model.get("model_name")
+ if model_name:
+ if model_name not in self._core_model_name_to_deployment_indices:
+ self._core_model_name_to_deployment_indices[model_name] = []
+ self._core_model_name_to_deployment_indices[model_name].append(idx)
+
+ self._update_team_model_index(model, idx)
+
+ def _build_model_id_to_deployment_index_map(self, model_list: list):
+ """
+ Build model index from model list to enable O(1) lookups immediately.
+ This is called during initialization to avoid the race condition where
+ requests arrive before model_id_to_deployment_index_map is populated.
+ """
+ # First populate the model_list
+ self._core_model_list = []
+ self._invalidate_model_group_info_cache()
+ self._invalidate_access_groups_cache()
+ for _, model in enumerate(model_list):
+ # Extract model_info from the model dict
+ model_info = model.get("model_info", {})
+ model_id = model_info.get("id")
+
+ # If no ID exists, generate one using the same logic as set_model_list
+ if model_id is None:
+ model_name = model.get("model_name", "")
+ litellm_params = model.get("litellm_params", {})
+ model_id = self._core_generate_model_id(model_name, litellm_params)
+ # Update the model_info in the original list
+ if "model_info" not in model:
+ model["model_info"] = {}
+ model["model_info"]["id"] = model_id
+
+ self._add_model_to_list_and_index_map(model=model, model_id=model_id)
+
+ def _core_get_model_ids(self, model_name: str | None = None, exclude_team_models: bool = False) -> list[str]:
+ """
+ if 'model_name' is none, returns all.
+
+ Returns list of model id's.
+
+ Optimized with O(1) or O(k) index lookup when model_name provided,
+ instead of O(n) linear scan.
+ """
+ ids: Final = []
+
+ if model_name is not None:
+ # O(1) lookup in model_name index, then O(k) iteration where k = deployments for this model_name
+ for model in self._core_deployments_named(model_name) or ():
+ if "model_info" in model and "id" in model["model_info"]:
+ if exclude_team_models and model["model_info"].get("team_id"):
+ continue
+ ids.append(model["model_info"]["id"])
+ else:
+ # When model_name is None, return all model IDs
+ # Use the index map keys for O(n) where n = total deployments
+ with self._deployment_index_lock:
+ id_indices: Final = tuple(self.model_id_to_deployment_index_map.items())
+ models: Final = tuple(self._core_model_list)
+ for model_id, idx in id_indices:
+ model = models[idx]
+ if "model_info" in model and "id" in model["model_info"]:
+ if exclude_team_models and model["model_info"].get("team_id"):
+ continue
+ ids.append(model_id)
+
+ return ids
+
+ def _core_get_candidate_model_ids_for_route(self, model: str, team_id: str | None = None) -> frozenset[str]:
+ """
+ Deployment ids that could serve ``model`` for ``team_id``, following the same
+ precedence ``_common_checks_available_deployment`` uses to build a candidate pool:
+ ``model_group_alias``, then a routing group, then the first matching early-resolve
+ path for a name that is not a ``model_name`` (team route, wildcard pattern via
+ ``get_deployments_by_pattern``, team pattern router, default deployment), then the
+ ``model_name`` and team indexes. Delegating to the router's own resolvers keeps this
+ aligned with how a route actually resolves rather than re-deriving it, and unlike
+ ``_common_checks_available_deployment`` it is read-only: it does not apply request
+ fallbacks and (with ``include_team_models`` left off) does not raise. Lets a pre-call
+ check tell a genuine cross-group route from same-group unavailability without leaking
+ deployment ids into request kwargs bound for the provider.
+ """
+ resolved: Final = self._core_get_model_from_alias(model=model) or model
+ routing_group_members: Final = self._get_routing_group_deployments(model=resolved, team_id=team_id)
+ if routing_group_members is not None:
+ return self._deployment_ids(routing_group_members)
+ early: Final = self._try_early_resolve_deployments_for_model_not_in_names(
+ model=resolved, request_team_id=team_id
+ )
+ if early is not None:
+ early_deployments: Final = early[1]
+ return self._deployment_ids(
+ (early_deployments,) if isinstance(early_deployments, Mapping) else early_deployments
+ )
+ return self._deployment_ids(self._core_get_all_deployments(model_name=resolved, team_id=team_id))
+
+ @staticmethod
+ def _deployment_ids(deployments: Sequence[Mapping[str, object]]) -> frozenset[str]:
+ return frozenset(
+ str(model_info["id"])
+ for deployment in deployments
+ for model_info in (deployment.get("model_info"),)
+ if isinstance(model_info, Mapping) and model_info.get("id") is not None
+ )
+
+ def _region_routable_deployments(
+ self,
+ deployments: Sequence[DeploymentTypedDict],
+ request_kwargs: Mapping[str, object] | None,
+ has_request_input: bool,
+ ) -> tuple[Mapping[str, Any], ...]:
+ """
+ `deployments` without the ones `_pre_call_checks` keeps this request away from for being outside its allowed
+ region, typed for the tag routing filter
+ """
+ allowed_model_region: Final = None if request_kwargs is None else request_kwargs.get("allowed_model_region")
+ if allowed_model_region is None or not self._core_enable_pre_call_checks or not has_request_input:
+ return tuple(deployments)
+ if not isinstance(allowed_model_region, str):
+ return ()
+ return tuple(
+ deployment
+ for deployment in deployments
+ if is_region_allowed(
+ litellm_params=LiteLLM_Params.model_validate(deployment["litellm_params"]),
+ allowed_model_region=allowed_model_region,
+ )
+ )
+
+ async def _async_routable_cooled_deployment_ids(
+ self,
+ model: str,
+ request_deployments: Sequence[DeploymentTypedDict],
+ cooldown_deployments: Iterable[str],
+ request_kwargs: Mapping[str, object],
+ has_request_input: bool,
+ ) -> frozenset[str]:
+ """
+ Ids of the cooling deployments in `request_deployments` that region and tag routing would send this request
+ to, each tag routing pick counted along with the picks it falls back to once that one is unavailable, judged
+ without recording a tag match on the request
+ """
+ cooled_ids: Final = frozenset(cooldown_deployments)
+ metadata_variable_name: Final = get_metadata_variable_name_from_kwargs(request_kwargs)
+ metadata: Final = request_kwargs.get(metadata_variable_name)
+ if metadata_variable_name in request_kwargs and not isinstance(metadata, Mapping):
+ return frozenset()
+ tag_request: Final[Mapping[str, Any]] = (
+ {**request_kwargs, metadata_variable_name: {**metadata}}
+ if isinstance(metadata, Mapping)
+ else request_kwargs
+ )
+ routable_ids = frozenset[str]() # rebind-ok: gains each pass's tag routing picks
+ remaining = tuple( # rebind-ok: each pass drops what tag routing just picked
+ deployment
+ for deployment in self._region_routable_deployments(request_deployments, request_kwargs, has_request_input)
+ if self._deployment_ids((deployment,)) <= cooled_ids
+ )
+ while remaining:
+ try:
+ picked = await get_deployments_for_tag(
+ ctx=self._core_ctx,
+ model=model,
+ healthy_deployments=remaining,
+ request_kwargs=tag_request,
+ metadata_variable_name=metadata_variable_name,
+ request_deployments=request_deployments,
+ )
+ except ValueError: # tag routing sends this request to none of the rest
+ break
+ picked_identities = frozenset(id(deployment) for deployment in picked)
+ rest = tuple(deployment for deployment in remaining if id(deployment) not in picked_identities)
+ if len(rest) == len(remaining):
+ break
+ routable_ids |= self._deployment_ids(
+ tuple(deployment for deployment in remaining if id(deployment) in picked_identities)
+ )
+ remaining = rest
+ return routable_ids
+
+ def _core_has_model_id(self, candidate_id: str) -> bool:
+ """
+ O(1) membership check for a deployment ID without allocating large lists.
+
+ Note: Call sites may pass a variable named `model` when it actually
+ contains a deployment ID. This helper expects the deployment ID string.
+
+ Uses the existing `model_id_to_deployment_index_map` which is kept
+ in sync by `_build_model_id_to_deployment_index_map` and model-list
+ mutation helpers.
+ """
+ return candidate_id in self.model_id_to_deployment_index_map
+
+ def _core_resolve_model_name_from_model_id(
+ self, model_id: str | None, custom_llm_provider: str | None = None
+ ) -> str | None:
+ """
+ Resolve model_name from model_id.
+
+ This method attempts to find the correct model_name to use with the router
+ so that litellm_params can be automatically injected from the model config.
+
+ Strategy:
+ 1. First, check if model_id directly matches a model_name or deployment ID
+ 2. If not, search through router's model_list to find a match by litellm_params.model
+ 3. If not, keep model_id when the router routes that name itself (an alias, a routing group, or a wildcard
+ whose deployment serves `custom_llm_provider`), else name it `custom_llm_provider/model_id` when only that
+ form routes to such a wildcard
+ 4. Return the model_name if found, None otherwise
+
+ Args:
+ model_id: The model_id extracted from decoded video_id
+ (could be model_name or litellm_params.model value)
+ custom_llm_provider: The provider extracted from the same id
+
+ Returns:
+ model_name if found, None otherwise. If None, the request will fall through
+ to normal flow using environment variables.
+ """
+ if not model_id:
+ return None
+
+ # Strategy 1: Check if model_id directly matches a model_name or deployment ID
+ if model_id in self._core_model_names:
+ return model_id
+ if self._core_has_model_id(model_id):
+ deployment = self._core_get_deployment(model_id=model_id)
+ if deployment is not None and deployment.model_name:
+ return deployment.model_name
+ return model_id
+
+ # Strategy 2: Search through router's model_list to find by litellm_params.model
+ all_models: Final = self._core_get_model_list(model_name=None)
+ if not all_models:
+ return None
+
+ for deployment in all_models:
+ litellm_params = deployment.get("litellm_params", {})
+ actual_model = litellm_params.get("model")
+
+ # Match by exact match or by checking if actual_model ends with /model_id or :model_id
+ # e.g., model_id="veo-2.0-generate-001" matches actual_model="vertex_ai/veo-2.0-generate-001"
+ matches = (
+ actual_model == model_id
+ or (actual_model and actual_model.endswith(f"/{model_id}"))
+ or (actual_model and actual_model.endswith(f":{model_id}"))
+ )
+
+ if matches:
+ model_name = deployment.get("model_name")
+ if model_name:
+ return model_name
+
+ if self._core_is_recognized_model(model_id):
+ return model_id
+ if custom_llm_provider is None:
+ return None
+ if self._core_pattern_router.routes_to_provider(model_id, custom_llm_provider):
+ return model_id
+ provider_prefixed: Final = f"{custom_llm_provider}/{model_id}"
+ if not model_id.startswith(f"{custom_llm_provider}/") and self._core_pattern_router.routes_to_provider(
+ provider_prefixed, custom_llm_provider
+ ):
+ return provider_prefixed
+ return None
+
+ def _core_resolve_served_model_name(self, model_name: str, served_model: str) -> str:
+ """
+ `served_model` when `model_name` routes to it (the alias target, the routing group member, or the name
+ itself), so a request can return to the group that served an earlier one, else `model_name`.
+ """
+ target: Final = self._core_get_model_from_alias(model_name) or model_name
+ routing_group: Final = self.get_routing_group(target)
+ members: Final = routing_group.models if routing_group is not None else ()
+ return served_model if served_model == target or served_model in members else model_name
+
+ def _core_map_team_model(
+ self, team_model_name: str | None, team_id: str, custom_llm_provider: str | None = None
+ ) -> str | None:
+ """
+ Resolve explicit team models or select a deployment for model-less calls.
+
+ Model-less calls with no provider use the first team deployment. With a provider, they
+ use the first team deployment of that provider, pinned by ID when its public name also
+ covers another provider.
+ """
+ models: Final = self._core_get_model_list(model_name=team_model_name, team_id=team_id)
+ if not models:
+ return None
+ if team_model_name is None and custom_llm_provider is not None:
+
+ def team_deployment_public_name(deployment: DeploymentTypedDict) -> str | None:
+ model_info: Final = _configured_model_info(deployment)
+ if model_info.get("team_id") != team_id:
+ return None
+ team_public_model_name: Final = model_info.get("team_public_model_name")
+ return (
+ team_public_model_name
+ if isinstance(team_public_model_name, str) and team_public_model_name
+ else deployment["model_name"]
+ )
+
+ matching_deployment: Final = next(
+ (
+ model
+ for model in models
+ if team_deployment_public_name(model) is not None
+ and provider_for_generic_call(model["litellm_params"]) == custom_llm_provider
+ ),
+ None,
+ )
+ if matching_deployment is None:
+ return None
+ public_name: Final = team_deployment_public_name(matching_deployment)
+ if public_name is None:
+ return None
+ same_public_name_deployments: Final = tuple(
+ candidate for candidate in models if team_deployment_public_name(candidate) == public_name
+ )
+ if all(
+ provider_for_generic_call(candidate["litellm_params"]) == custom_llm_provider
+ for candidate in same_public_name_deployments
+ ):
+ return public_name
+ deployment_id: Final = _configured_model_info(matching_deployment).get("id")
+ return deployment_id if isinstance(deployment_id, str) else None
+ for model in models:
+ if model.get("model_info", {}).get("team_id") == team_id:
+ if team_model_name is None:
+ # No model was specified (e.g. vector store endpoints).
+ # Return the deployment's public model name so the router
+ # can route to it and inject the BYOK API key.
+ return model.get("model_info", {}).get("team_public_model_name") or model.get("model_name")
+ return team_model_name
+
+ # No team-scoped deployment found; wildcard/pattern routes are
+ # handled downstream by the pattern_router in _common_checks_available_deployment.
+ return None
+
+ def _core_should_include_deployment(
+ self, model_name: str, model: DeploymentTypedDict | dict, team_id: str | None = None
+ ) -> bool:
+ """
+ Get the team-specific model name if team_id matches the deployment.
+ """
+ if (
+ team_id is not None
+ and (model.get("model_info") or {}).get("team_id") == team_id
+ and model_name == (model.get("model_info") or {}).get("team_public_model_name")
+ ):
+ return True
+ elif model_name is not None and model["model_name"] == model_name:
+ # Fallback: check by internal model_name for non-team deployments
+ # or deployments that haven't been migrated to team_public_model_name yet
+ model_team_id: Final = (model.get("model_info") or {}).get("team_id")
+ if (
+ team_id is None # requester has no team constraint
+ or model_team_id is None # global deployment - accessible to all teams
+ or model_team_id == team_id # deployment belongs to requester's team
+ ):
+ return True
+ # No match: deployment is for a different team or doesn't match the requested model
+ return False
+
+ def _core_get_all_deployments(
+ self,
+ model_name: str,
+ model_alias: str | None = None,
+ team_id: str | None = None,
+ ) -> list[DeploymentTypedDict]:
+ """
+ Return all deployments of a model name
+
+ Used for accurate 'get_model_list'.
+
+ if team_id specified, only return team-specific models
+
+ Optimized with O(1) index lookup instead of O(n) linear scan.
+
+ Note: when team_id is provided, O(1) lookup in
+ `team_model_to_deployment_indices` only applies when `model_name` is the
+ team public model name. If a caller passes an internal deployment model
+ name (for example, `model_name__`), this method falls back
+ to the standard model-name index / scan path.
+ """
+ returned_models: Final[list[DeploymentTypedDict]] = []
+
+ # O(1) lookup in team_model index when team_id is provided
+ if team_id is not None:
+ team_models: Final = self._team_deployments_named(team_id, model_name)
+ if team_models is not None:
+ # O(k) where k = team deployments for this model_name (typically 1-10)
+ for model in team_models:
+ if not self._core_should_include_deployment(model_name=model_name, model=model, team_id=team_id):
+ continue
+ if model_alias is not None:
+ alias_model = model.copy()
+ alias_model["model_name"] = model_alias
+ returned_models.append(alias_model)
+ else:
+ returned_models.append(model)
+ if returned_models:
+ return returned_models
+
+ # O(1) lookup in model_name index
+ named_models: Final = self._core_deployments_named(model_name)
+ if named_models is not None:
+ # O(k) where k = deployments for this model_name (typically 1-10)
+ for model in named_models:
+ if self._core_should_include_deployment(model_name=model_name, model=model, team_id=team_id):
+ if model_alias is not None:
+ # Optimized: Use shallow copy since we only modify top-level model_name
+ # This is much faster than deepcopy for nested dict structures
+ alias_model = model.copy()
+ alias_model["model_name"] = model_alias
+ returned_models.append(alias_model)
+ else:
+ returned_models.append(model)
+ elif team_id is not None:
+ # Fallback: if team_id is provided and model_name not in index,
+ # check if model_name matches any team_public_model_name
+ # O(n) scan but only when team_id lookup fails
+ for _idx, model in enumerate(self._core_model_list):
+ if self._core_should_include_deployment(model_name=model_name, model=model, team_id=team_id):
+ if model_alias is not None:
+ # Optimized: Use shallow copy since we only modify top-level model_name
+ alias_model = model.copy()
+ alias_model["model_name"] = model_alias
+ returned_models.append(alias_model)
+ else:
+ returned_models.append(model)
+
+ return returned_models
+
+ def _core_get_model_names(self, team_id: str | None = None) -> list[str]:
+ """
+ Returns all possible model names for the router, including models defined via model_group_alias.
+
+ If a team_id is provided, only deployments configured with that team_id (i.e. team‐specific models)
+ will yield their team public name.
+ """
+ deployments: Final = self._core_get_model_list() or []
+ model_names: Final = []
+
+ for deployment in deployments:
+ model_info = deployment.get("model_info")
+ if self._is_team_specific_model(model_info):
+ team_model_name = self._get_team_specific_model(deployment=deployment, team_id=team_id)
+ if team_model_name:
+ model_names.append(team_model_name)
+ else:
+ model_names.append(deployment.get("model_name", ""))
+
+ return model_names
+
+ def _core_get_fully_blocked_model_names(self) -> set[str]:
+ """
+ Returns the set of model_names where every backing deployment has `blocked=True`.
+
+ Used by `/v1/models` to hide paused models from client listings while still
+ surfacing them on admin endpoints (e.g. `/model/info`). A model with at least
+ one non-blocked deployment is still serviceable and remains visible.
+ """
+ deployments: Final = self._core_get_model_list() or []
+ blocked_by_name: Final[dict[str, bool]] = {}
+ for deployment in deployments:
+ name = deployment.get("model_name") or ""
+ if not name:
+ continue
+ is_blocked = (deployment.get("model_info") or {}).get("blocked") is True
+ if name in blocked_by_name:
+ blocked_by_name[name] = blocked_by_name[name] and is_blocked
+ else:
+ blocked_by_name[name] = is_blocked
+ return {name for name, fully_blocked in blocked_by_name.items() if fully_blocked}
+
+ @staticmethod
+ def _core_are_all_deployments_blocked(
+ deployments: Sequence[DeploymentTypedDict],
+ ) -> bool:
+ return len(deployments) > 0 and all(
+ (deployment.get("model_info") or {}).get("blocked") is True for deployment in deployments
+ )
+
+ def _is_model_fully_blocked(self, model: str) -> bool:
+ deployments: Final = self._core_get_model_list(model_name=model) or []
+ return self._core_are_all_deployments_blocked(deployments=deployments)
+
+ async def _core_async_get_fully_unhealthy_model_names(self) -> set[str]:
+ """
+ Returns the set of model names where every backing deployment is currently
+ marked unhealthy by background health checks (and the health state is not stale).
+
+ Used by `/v1/models?healthy_only=true` to hide models that cannot serve any
+ request. A model with at least one healthy (or unknown-health) deployment
+ remains visible. Returns an empty set when no health state is available, so
+ callers fail open to the unfiltered listing.
+
+ Notes:
+ - Mirrors `_async_filter_health_check_unhealthy_deployments`: when
+ `allowed_fails_policy` is set, cooldown is the sole routing exclusion
+ mechanism, so nothing is hidden here either.
+ - Team-specific public model names (`team_public_model_name`) are
+ aggregated alongside `model_name`, so team aliases of fully-unhealthy
+ deployments are hidden too (unlike `get_fully_blocked_model_names`,
+ which matches `model_name` only).
+ - Wildcard routes (e.g. `openai/*`) are matched by their literal
+ deployment name only; models expanded from a wildcard route are not
+ hidden (fail open).
+ - Intentionally diverges from the routing-time safety net (which
+ bypasses the health filter when every candidate is unhealthy and
+ still attempts the request): hiding here is presentation-only —
+ it answers "should this model be advertised?", not "should a
+ request for it still be attempted?". A hidden model can still be
+ called directly.
+ """
+ if self._core_allowed_fails_policy is not None:
+ return set()
+ unhealthy_ids: Final = await self._core_health_state_cache.async_get_unhealthy_deployment_ids()
+ if not unhealthy_ids:
+ return set()
+ deployments: Final = self._core_get_model_list() or []
+ unhealthy_by_name: Final[dict[str, bool]] = {}
+ for deployment in deployments:
+ model_info = deployment.get("model_info") or {}
+ names = [deployment.get("model_name") or ""]
+ team_public_model_name = model_info.get("team_public_model_name")
+ if team_public_model_name:
+ names.append(team_public_model_name)
+ is_unhealthy = model_info.get("id") in unhealthy_ids
+ for name in names:
+ if not name:
+ continue
+ if name in unhealthy_by_name:
+ unhealthy_by_name[name] = unhealthy_by_name[name] and is_unhealthy
+ else:
+ unhealthy_by_name[name] = is_unhealthy
+ return {name for name, fully_unhealthy in unhealthy_by_name.items() if fully_unhealthy}
+
+ def _get_team_specific_model(self, deployment: DeploymentTypedDict, team_id: str | None = None) -> str | None:
+ """
+ Get the team-specific model name if team_id matches the deployment.
+
+ Args:
+ deployment: DeploymentTypedDict - The model deployment
+ team_id: Optional[str] - If passed, will return router models set with a `team_id` matching the passed `team_id`.
+
+ Returns:
+ str: The `team_public_model_name` if team_id matches
+ None: If team_id doesn't match or no team info exists
+ """
+ model_info: Final[dict | None] = deployment.get("model_info") or {}
+ if model_info is None:
+ return None
+ if team_id == model_info.get("team_id"):
+ return model_info.get("team_public_model_name")
+ return None
+
+ def _is_team_specific_model(self, model_info: dict | None) -> bool:
+ """
+ Check if model info contains team-specific configuration.
+
+ Args:
+ model_info: Model information dictionary
+
+ Returns:
+ bool: True if model has team-specific configuration
+ """
+ return bool(model_info and model_info.get("team_id"))
+
+ def _core_get_model_list_from_model_alias(self, model_name: str | None = None) -> list[DeploymentTypedDict]:
+ """
+ Helper function to get model list from model alias.
+
+ Used by `.get_model_list` to get model list from model alias.
+ """
+ returned_models: Final[list[DeploymentTypedDict]] = []
+
+ if model_name is not None:
+ # Fast path: direct dict lookup avoids scanning all aliases for non-alias model names.
+ if model_name not in self._core_model_group_alias:
+ return returned_models
+ alias_items = [(model_name, self._core_model_group_alias[model_name])]
+ else:
+ alias_items = list(self._core_model_group_alias.items())
+
+ for model_alias, model_value in alias_items:
+ if isinstance(model_value, str):
+ _router_model_name: str = model_value
+ elif isinstance(model_value, dict):
+ _model_value = RouterModelGroupAliasItem(**model_value)
+ if _model_value["hidden"] is True and model_name is None:
+ continue
+ _router_model_name = _model_value["model"]
+ else:
+ continue
+
+ if (alias_group := self.get_routing_group(_router_model_name)) is not None:
+ returned_models.extend(
+ {**row, "model_name": model_alias} for row in self._materialize_routing_group_rows((alias_group,))
+ )
+ else:
+ returned_models.extend(
+ self._core_get_all_deployments(model_name=_router_model_name, model_alias=model_alias)
+ )
+
+ return returned_models
+
+ def _core_get_model_list_from_routing_groups(self, model_name: str | None = None) -> Sequence[DeploymentTypedDict]:
+ """
+ Callable routing groups materialized as model-list rows, mirroring
+ `get_model_list_from_model_alias`: each member deployment is emitted
+ under the group's name (via `get_all_deployments`' `model_alias`
+ rewrite), which is what surfaces groups in `get_model_names`,
+ `/v1/models` discovery, `get_model_group_usage`, and the
+ blocked/unhealthy hiding that all read `get_model_list`.
+ """
+ if model_name is not None:
+ group: Final = self.get_routing_group(model_name)
+ return self._materialize_routing_group_rows((group,)) if group is not None else ()
+ cached: Final = self._routing_group_rows
+ if cached is not None:
+ return cached
+ rows: Final = self._materialize_routing_group_rows(
+ tuple(
+ callable_group
+ for name in self._core_routing_groups
+ if (callable_group := self.get_routing_group(name)) is not None
+ )
+ )
+ self._routing_group_rows = rows
+ return rows
+
+ def _materialize_routing_group_rows(self, groups: tuple[RoutingGroup, ...]) -> tuple[DeploymentTypedDict, ...]:
+ return tuple(
+ self._as_routing_group_row(apply_routing_group_priority(group, member, deployment))
+ for group in groups
+ for member in group.models
+ for deployment in self._core_get_all_deployments(model_name=member, model_alias=group.group_name)
+ )
+
+ @staticmethod
+ def _as_routing_group_row(deployment: DeploymentTypedDict) -> DeploymentTypedDict:
+ """
+ A member deployment re-emitted under its group's name must not carry
+ the member's `access_groups`: access groups grant member names, never
+ the group, so inheriting them here would let a key holding a member's
+ access group list and call the whole group.
+ """
+ model_info: Final = {k: v for k, v in (deployment.get("model_info") or {}).items() if k != "access_groups"}
+ return {**deployment, "model_info": model_info}
+
+ TIER_PARAMS_NEVER_DROPPED: Final = frozenset(all_litellm_params) | frozenset(
+ {
+ "additional_drop_params",
+ "drop_params",
+ "messages",
+ "model",
+ "extra_headers",
+ "max_tokens",
+ "max_completion_tokens",
+ }
+ )
+
+ @staticmethod
+ def _declared_param_allowlist(params: Mapping[str, object]) -> frozenset[str]:
+ declared: Final = params.get("allowed_openai_params")
+ if not isinstance(declared, (list, tuple, set, frozenset)):
+ return frozenset()
+ return frozenset(entry for entry in declared if isinstance(entry, str))
+
+ @staticmethod
+ def _deployment_accepts_param(deployment: DeploymentTypedDict, group: str, param: str) -> bool:
+ deployment_params: Final = deployment.get("litellm_params")
+ if not deployment_params:
+ return True
+ if param in PythonCore._declared_param_allowlist(deployment_params):
+ return True
+ if declared_authenticating_provider(
+ str(deployment_params.get("model") or ""), deployment_params.get("custom_llm_provider")
+ ):
+ return True
+ return PythonCore._deployment_supports_param(deployment, group, param)
+
+ @staticmethod
+ def _capability_model_and_provider(deployment_params: LiteLLMParamsTypedDict, group: str) -> tuple[str, str]:
+ model_label: Final = str(deployment_params.get("model") or group)
+ authenticating_provider: Final = declared_authenticating_provider(
+ model_label, deployment_params.get("custom_llm_provider")
+ )
+ if authenticating_provider is not None:
+ return model_label.removeprefix(f"{authenticating_provider}/"), authenticating_provider
+ model, custom_llm_provider, _, _ = python_host.invoke("get_llm_provider")(
+ model=model_label,
+ custom_llm_provider=deployment_params.get("custom_llm_provider"),
+ litellm_params=LiteLLM_Params.model_validate(deployment_params),
+ )
+ return model, custom_llm_provider
+
+ @staticmethod
+ def _deployment_supports_param(deployment: DeploymentTypedDict, group: str, param: str) -> bool:
+ deployment_params: Final = deployment.get("litellm_params")
+ if not deployment_params:
+ return True
+ deployment_model_info: Final = deployment.get("model_info")
+ base_model: Final = (
+ deployment_model_info.get("base_model") if deployment_model_info else None
+ ) or deployment_params.get("base_model")
+ try:
+ model, custom_llm_provider = PythonCore._capability_model_and_provider(deployment_params, group)
+ supported: Final = python_host.invoke("get_supported_openai_params")(
+ model=model,
+ custom_llm_provider=custom_llm_provider,
+ base_model=base_model if isinstance(base_model, str) else None,
+ )
+ except Exception as e: # noqa: BLE001 # best-effort filter: an unresolvable provider must not narrow the request
+ verbose_router_logger.debug(
+ "litellm.router.py::_deployment_supports_param: keeping %s for model=%s. Got - %s", param, group, e
+ )
+ return True
+ return supported is None or param in supported
+
+ def _tier_params_the_target_accepts(
+ self, model: str, tier_params: Mapping[str, object], request_kwargs: Mapping[str, object]
+ ) -> Mapping[str, object]:
+ """Drop an OpenAI param that no deployment behind ``model`` declares.
+
+ A tier's litellm_params are an operator override applied to every request the tier routes,
+ so one the target cannot take turns that whole tier into a 400 raised before the request
+ leaves the proxy. The candidates are exactly what get_optional_params can reject, asked of
+ the module that raises, so credentials and endpoint controls are never at risk.
+
+ TIER_PARAMS_NEVER_DROPPED is excluded on top of that, for two reasons. No provider lists a
+ litellm control among its supported params, so "no deployment declares it" means litellm
+ consumes it rather than that the target refuses it, and dropping one changes litellm's own
+ behavior: dropping drop_params or additional_drop_params silently disables the sanitization
+ the operator configured. Providers do list extra_headers, but it carries auth, tenancy and
+ routing information, so sending fewer headers than configured is worse than today's error.
+ Token ceilings stay for the same reason: a tier's max_tokens or max_completion_tokens is a
+ cost bound, and dropping it would let a caller's own larger value through where today the
+ mismatch fails loudly.
+
+ The trade this filter makes is a param for a working request, which is right for one that
+ only shapes how the model answers and wrong for anything else.
+
+ A param survives if ANY deployment could take it, because routing has not chosen one yet,
+ and it survives both an unresolvable provider and a group with no deployments, because a
+ best-effort filter must never narrow what the request already did.
+
+ A github_copilot or chatgpt deployment counts as accepting everything, decided before any
+ lookup: resolving either provider runs its OAuth device flow, so a capability question
+ asked from the routing path can freeze the event loop for minutes waiting on a human.
+
+ allowed_openai_params is the documented escape hatch for an outdated or incomplete
+ supported-params list: request-time validation extends the supported list with it before
+ comparing. The filter asks the same question, so a param named by the allowlist on the tier
+ overlay, the request, or a deployment's own litellm_params is never a drop candidate.
+ """
+ deployments: Final = self._core_get_model_list(model_name=model) or ()
+ if not deployments:
+ return tier_params
+ allowlisted: Final = self._declared_param_allowlist(tier_params) | self._declared_param_allowlist(
+ request_kwargs
+ )
+ candidates: Final = provider_rejectable_params(tier_params) - self.TIER_PARAMS_NEVER_DROPPED - allowlisted
+ unsupported: Final = frozenset(
+ param
+ for param in candidates
+ if not any(self._deployment_accepts_param(deployment, model, param) for deployment in deployments)
+ )
+ if not unsupported:
+ return tier_params
+ verbose_router_logger.warning(
+ "litellm.router.py: dropping tier params %s for model=%s, no deployment behind it declares them",
+ ", ".join(sorted(unsupported)),
+ model,
+ )
+ return MappingProxyType({key: value for key, value in tier_params.items() if key not in unsupported})
+
+ def _core_get_model_list(
+ self, model_name: str | None = None, team_id: str | None = None
+ ) -> list[DeploymentTypedDict] | None:
+ """
+ Includes router model_group_alias'es as well
+
+ if team_id specified, returns matching team-specific models
+ """
+ # Note: model_list and model_group_alias are always initialized in __init__
+ # so hasattr checks are unnecessary
+ returned_models: list[DeploymentTypedDict] = []
+
+ if model_name is not None:
+ returned_models.extend(self._core_get_all_deployments(model_name=model_name, team_id=team_id))
+
+ returned_models.extend(self._core_get_model_list_from_model_alias(model_name=model_name))
+ returned_models.extend(self._core_get_model_list_from_routing_groups(model_name=model_name))
+
+ if len(returned_models) == 0 and model_name is not None:
+ returned_models.extend(self._get_wildcard_deployments(model_name=model_name, team_id=team_id))
+
+ if model_name is None:
+ returned_models += self._core_model_list
+
+ return returned_models
+
+ def _get_wildcard_deployments(self, model_name: str, team_id: str | None = None) -> list[DeploymentTypedDict]:
+ """
+ The deployments of the wildcard routes matching model_name (the proxy-wide
+ ones, plus team_id's own when given), each emitted under model_name.
+ """
+ team_router: Final = self._core_team_pattern_routers.get(team_id) if team_id is not None else None
+ matches: Final = [
+ *self._core_pattern_router.get_deployments_by_pattern(model=model_name),
+ *(team_router.get_deployments_by_pattern(model=model_name) if team_router is not None else ()),
+ ]
+ return [{**DeploymentTypedDict(**m), "model_name": model_name} for m in matches]
+
+ def _core_wildcard_route_deployments(self, model: str, team_id: str | None) -> Sequence[Mapping[str, object]]:
+ """The deployments routing serves ``model`` from through a wildcard: ``team_id``'s own pattern
+ matching it, else the most specific proxy-wide pattern, never both."""
+ team_router: Final = self._core_team_pattern_routers.get(team_id) if team_id is not None else None
+ team_owned: Final[Sequence[Mapping[str, object]]] = (
+ team_router.get_deployments_by_pattern(model=model) if team_router is not None else ()
+ )
+ return team_owned or self._core_pattern_router.get_deployments_by_pattern(model=model)
+
+ def _core_wildcard_routed_models(self, model: str, team_id: str | None) -> frozenset[str]:
+ """The models wildcard_route_deployments would route ``model`` to, without copying the deployments."""
+ team_router: Final = self._core_team_pattern_routers.get(team_id) if team_id is not None else None
+ team_owned: Final = (
+ team_router.get_models_by_pattern(model=model) if team_router is not None else frozenset[str]()
+ )
+ return team_owned or self._core_pattern_router.get_models_by_pattern(model=model)
+
+ def _core_team_ptu_ceiling(self, team_id: str, model_group: str) -> PTUTeamCeiling | None:
+ """The per-minute ceiling ``team_id``'s PTU shares set on the group serving ``model_group``,
+ else None, kept until the model list next changes."""
+ if team_id not in self._ptu_share_teams():
+ return None
+ return self._cached_team_ptu_ceiling(team_id, model_group)
+
+ def _collect_ptu_share_teams(self) -> frozenset[str]:
+ return ptu_share_teams(self._core_get_model_list() or ())
+
+ def _resolve_team_ptu_ceiling(self, team_id: str, model_group: str) -> PTUTeamCeiling | None:
+ return team_ptu_ceiling(
+ self._core_get_model_list() or (),
+ self._core_model_list,
+ team_id,
+ model_group,
+ lambda name: self._core_wildcard_route_deployments(name, team_id),
+ )
+
+ def _core_get_model_list_of_routed_group(
+ self, model_group: str, team_id: str | None = None
+ ) -> list[DeploymentTypedDict]:
+ """
+ The deployments a request the router has already resolved to model_group is
+ served from: team_id's own deployments under that public name when the team has
+ any, else the ones named model_group, the routing group of that name, or the
+ wildcard route matching it when neither exists.
+
+ Unlike get_model_list, model_group's own model_group_alias entry is not
+ followed. The router resolves an alias exactly once, so a group reached as
+ an alias target is served by its own deployments, never by a second hop:
+ in the chain X -> T -> U a request to X is served from T's deployments.
+ """
+ if team_id is not None and self._team_deployments_named(team_id, model_group):
+ return self._core_get_all_deployments(model_name=model_group, team_id=team_id)
+ own: Final = cast( # cast-ok: the private alias returns what get_all_deployments returns
+ list[DeploymentTypedDict], self._get_all_deployments(model_name=model_group)
+ )
+ named: Final = [*own, *self._core_get_model_list_from_routing_groups(model_name=model_group)]
+ return named or self._get_wildcard_deployments(model_name=model_group)
+
+ def _core_resolved_litellm_models(
+ self, model_name: str, team_id: str | None = None, *, use_base_model: bool = False
+ ) -> tuple[str, ...]:
+ """The provider model strings `model_name` can actually be served by on this proxy.
+
+ `get_model_list` composes every channel the request path itself uses (exact name,
+ model_group_alias, routing groups, wildcards), so this answers "which models will
+ answer a call to this name" rather than "what did the admin call it": the deployment
+ name is admin-arbitrary, and two names over one provider model are one model.
+
+ Empty when the name resolves to no deployment. That is not the same fact as "the
+ call will fail" - a provider-qualified public name is served by the SDK with no
+ deployment behind it - so the fallback for an empty result is the caller's policy,
+ never this function's.
+ """
+ if use_base_model:
+ return tuple(
+ backend_model
+ for deployment in self._core_get_model_list(model_name=model_name, team_id=team_id) or ()
+ if (backend_model := self._cost_map_backend_model(deployment))
+ )
+ return tuple(
+ litellm_model
+ for deployment in self._core_get_model_list(model_name=model_name, team_id=team_id) or ()
+ if isinstance(litellm_model := deployment.get("litellm_params", {}).get("model"), str) and litellm_model
+ )
+
+ def _invalidate_model_group_info_cache(self) -> None:
+ """Invalidate the cached model group info.
+
+ Call this whenever self._core_model_list is modified to ensure the cache is rebuilt.
+ Also clears the auth-layer zero-cost cache, which depends on the same
+ ``ModelGroupInfo`` data — without this, an in-place pricing update on
+ an existing deployment (same model count) would keep a stale ``True``
+ result and bypass budget enforcement.
+ """
+ self._cached_get_model_group_info.cache_clear()
+ self._core_cached_deployment_model_info.cache_clear()
+ self._ptu_share_teams.cache_clear()
+ self._cached_team_ptu_ceiling.cache_clear()
+ self._cached_deployment_tracks_usage.cache_clear()
+ self.served_openai_models.cache_clear()
+ self._zero_cost_cache.clear()
+ self._routing_group_rows = None
+
+ def _invalidate_access_groups_cache(self) -> None:
+ """Invalidate the cached access groups.
+
+ Call this whenever self._core_model_list is modified to ensure the cache is rebuilt. It also
+ advances ``registry().version``, so every write path (``apply``, the old ``set_model_list``
+ and ``add_deployment``) moves it.
+ """
+ self._access_groups_cache = None
+ self._registry_version += 1
+
+ def _core_get_model_access_groups(
+ self,
+ model_name: str | None = None,
+ model_access_group: str | None = None,
+ team_id: str | None = None,
+ ) -> dict[str, list[str]]:
+ """
+ If model_name is provided, only return access groups for that model.
+
+ Parameters:
+ - model_name: Optional[str] - the received model name from the user (can be a wildcard route). If set, will only return access groups for that model.
+ - model_access_group: Optional[str] - the received model access group from the user. If set, will only return models for that access group.
+ - team_id: Optional[str] - the team id, to resolve team-specific models
+ """
+ # Check if this is the no-args hot path (cacheable)
+ _use_cache: Final = model_name is None and model_access_group is None and team_id is None
+
+ # Return cached result for the no-args hot path
+ if _use_cache and self._access_groups_cache is not None:
+ return self._access_groups_cache
+
+ from collections import defaultdict
+
+ access_groups: Final = defaultdict(list)
+
+ model_list: Final = self._core_get_model_list(model_name=model_name, team_id=team_id)
+ if model_list:
+ for m in model_list:
+ _model_info = m.get("model_info")
+ if _model_info:
+ for group in _model_info.get("access_groups", []) or []:
+ if model_access_group is not None:
+ if group == model_access_group:
+ model_name = m["model_name"]
+ access_groups[group].append(model_name)
+ else:
+ model_name = m["model_name"]
+ access_groups[group].append(model_name)
+
+ # Cache the result for the no-args hot path
+ if _use_cache:
+ self._access_groups_cache = dict(access_groups)
+ return self._access_groups_cache
+
+ return access_groups
+
+ def _core_is_model_access_group_for_wildcard_route(self, model_access_group: str) -> bool:
+ """
+ Return True if model access group is a wildcard route
+ """
+ # GET ACCESS GROUPS
+ access_groups: Final = self._core_get_model_access_groups(model_access_group=model_access_group)
+
+ if len(access_groups) == 0:
+ return False
+
+ models: Final = access_groups.get(model_access_group, [])
+
+ for model in models:
+ # CHECK IF MODEL ACCESS GROUP IS A WILDCARD ROUTE
+ if self._core_pattern_router.route(request=model) is not None:
+ return True
+
+ return False
+
+ def _core_get_settings(self) -> dict[str, object]: # mutable-ok: the settings snapshot is built fresh per call
+ """
+ Get router settings method, returns a dictionary of the settings and their values.
+ For example get the set values for routing_strategy_args, routing_strategy, allowed_fails, cooldown_time, num_retries, timeout, max_retries, retry_after
+ """
+ _all_vars: Final = vars(self)
+ _settings_to_return: Final = {}
+ vars_to_include: Final = [
+ "routing_strategy_args",
+ "routing_strategy",
+ "allowed_fails",
+ "cooldown_time",
+ "num_retries",
+ "timeout",
+ "max_retries",
+ "retry_after",
+ "fallbacks",
+ "context_window_fallbacks",
+ "model_group_retry_policy",
+ "retry_policy",
+ "model_group_alias",
+ "enable_weighted_failover",
+ "enable_mid_stream_fallback_continuation",
+ "enable_tag_filtering",
+ "tag_routing_prefix",
+ "fallback_on_unsupported_params",
+ ]
+
+ for var in vars_to_include:
+ stored = var if var in _all_vars else "_core_" + var
+ if stored in _all_vars:
+ _settings_to_return[var] = _all_vars[stored]
+ if (
+ var == "routing_strategy_args"
+ and self._core_routing_strategy == "latency-based-routing"
+ and self.lowestlatency_logger is not None
+ ):
+ _settings_to_return[var] = self.lowestlatency_logger.routing_args.json()
+
+ _settings_to_return["routing_groups"] = [
+ group.model_dump(exclude=frozenset(("model_priorities",)) if group.model_priorities is None else None)
+ for group in self._core_routing_groups.values()
+ ]
+ return _settings_to_return
+
+ def _switch_routing_strategy(self, routing_strategy: str | None, kwargs: Mapping[str, object]) -> None:
+ if routing_strategy == "lar1":
+ from litellm.router_strategy.lar1_routing import apply_lar1_routing_strategy
+
+ apply_lar1_routing_strategy(self._core_ctx, kwargs.get("routing_strategy_args"))
+ return
+ self.routing_strategy_init(
+ routing_strategy=routing_strategy, routing_strategy_args=kwargs.get("routing_strategy_args", {})
+ )
+
+ def _core_update_settings(self, **kwargs) -> None:
+ """
+ Update the router settings.
+ """
+ _int_settings: Final = [
+ "timeout",
+ "num_retries",
+ "retry_after",
+ "allowed_fails",
+ "cooldown_time",
+ ]
+
+ _existing_router_settings: Final = self._core_get_settings()
+ model_group_alias_before: Final = self._core_model_group_alias
+ rebuild_routing_groups = False
+ routing_args_updated = False
+ for var in kwargs:
+ if var in RUNTIME_UPDATABLE_ROUTER_SETTINGS:
+ if var in _int_settings:
+ _casted_value = int(kwargs[var])
+ setattr(self, var, _casted_value)
+ elif var == "routing_groups":
+ rebuild_routing_groups = True
+ elif var == "optional_pre_call_checks":
+ self.set_optional_pre_call_checks(kwargs[var])
+ elif var == "enable_mid_stream_fallback_continuation":
+ self._set_mid_stream_fallback_continuation(kwargs[var])
+ elif var == "retry_policy":
+ self._set_retry_policy(kwargs[var])
+ else:
+ value = kwargs[var]
+ # only run routing strategy init if it has changed
+ if var == "routing_strategy":
+ value = self._normalize_strategy(value)
+ if _existing_router_settings["routing_strategy"] != value:
+ self._switch_routing_strategy(value, kwargs)
+ rebuild_routing_groups = True
+ elif var == "routing_strategy_args":
+ routing_args_updated = value != self.routing_strategy_args
+ setattr(self, var, value)
+ else:
+ verbose_router_logger.debug("Setting %s is not allowed", var)
+
+ if routing_args_updated:
+ self._apply_updated_routing_strategy_args()
+
+ if self._core_model_group_alias != model_group_alias_before:
+ self._invalidate_model_group_info_cache()
+
+ if rebuild_routing_groups:
+ routing_groups_input: Final = kwargs.get("routing_groups", self._routing_groups_input)
+ self._init_routing_groups(routing_groups_input)
+ self._routing_groups_input = routing_groups_input
+ verbose_router_logger.debug("Updated Router settings: %s", self._core_get_settings())
+
+ @staticmethod
+ def _is_flag_enabled(value: object) -> bool:
+ return value is True or (isinstance(value, str) and str_to_bool(value) is True)
+
+ def _set_mid_stream_fallback_continuation(self, enabled: object) -> None:
+ self.enable_mid_stream_fallback_continuation = self._is_flag_enabled(enabled)
+ if self.enable_mid_stream_fallback_continuation:
+ self._register_continuation_prefill_check()
+
+ def _set_retry_policy(self, value: object) -> None:
+ policy: Final = RetryPolicy.model_validate(value) if isinstance(value, dict) else value
+ if policy is None or isinstance(policy, RetryPolicy):
+ self.retry_policy = policy
+
+ def _get_client(self, deployment, kwargs, client_type=None):
+ """
+ Returns the appropriate client based on the given deployment, kwargs, and client_type.
+
+ Parameters:
+ deployment (dict): The deployment dictionary containing the clients.
+ kwargs (dict): The keyword arguments passed to the function.
+ client_type (str): The type of client to return.
+
+ Returns:
+ The appropriate client based on the given client_type and kwargs.
+ """
+ model_id: Final = deployment["model_info"]["id"]
+ parent_otel_span: Final[Span | None] = get_parent_otel_span_from_kwargs(kwargs)
+ if client_type == "max_parallel_requests":
+ cache_key = f"{model_id}_max_parallel_requests_client"
+ client = self._core_cache.get_cache(key=cache_key, local_only=True, parent_otel_span=parent_otel_span)
+ if client is None:
+ InitalizeCachedClient.set_max_parallel_requests_client(ctx=self._core_ctx, model=deployment)
+ client = self._core_cache.get_cache(key=cache_key, local_only=True, parent_otel_span=parent_otel_span)
+ return client
+ elif client_type == "async":
+ if (
+ deployment["litellm_params"].get( # pyright: ignore[reportUnknownMemberType] # legacy mapping
+ "kubernetes_pod_discovery"
+ )
+ is True
+ ):
+ return None
+ if kwargs.get("stream") is True:
+ cache_key = f"{model_id}_stream_async_client"
+ client = self._core_cache.get_cache(key=cache_key, local_only=True, parent_otel_span=parent_otel_span)
+ return client
+ else:
+ cache_key = f"{model_id}_async_client"
+ client = self._core_cache.get_cache(key=cache_key, local_only=True, parent_otel_span=parent_otel_span)
+ return client
+ else:
+ if (
+ deployment["litellm_params"].get( # pyright: ignore[reportUnknownMemberType] # legacy mapping
+ "kubernetes_pod_discovery"
+ )
+ is True
+ ):
+ return None
+ if kwargs.get("stream") is True:
+ cache_key = f"{model_id}_stream_client"
+ client = self._core_cache.get_cache(key=cache_key, parent_otel_span=parent_otel_span)
+ return client
+ else:
+ cache_key = f"{model_id}_client"
+ client = self._core_cache.get_cache(key=cache_key, parent_otel_span=parent_otel_span)
+ return client
+
+ def _count_pre_call_check_tokens(
+ self,
+ messages: Sequence[Mapping[str, object]] | None,
+ input: str | list[object] | None,
+ request_kwargs: Mapping[str, object] | None = None,
+ tokenizer_model: str = "",
+ ) -> int:
+ """
+ Count input tokens for context-window pre-call checks.
+
+ Chat Completions send `messages`; the Responses API sends `input` (a string or
+ a list of Responses input items) plus an optional `instructions` system prompt.
+ The Responses payload is normalized to chat messages via the shared
+ LiteLLMCompletionResponsesConfig transform so the same token_counter path covers
+ both API surfaces and `instructions` tokens are included in the count.
+
+ Prompt content the message list never carries is read from `request_kwargs`:
+ `tools` (Chat Completions, Responses and Anthropic Messages shapes) and the
+ Anthropic Messages top-level `system` block.
+ """
+ from litellm.llms.anthropic.messages.native.utils import (
+ anthropic_system_to_openai_message,
+ )
+
+ extras: Final = request_kwargs if request_kwargs is not None else MappingProxyType({})
+ raw_instructions: Final = extras.get("instructions")
+ instructions: Final = raw_instructions if isinstance(raw_instructions, str) else None
+ raw_tools: Final = extras.get("tools")
+ tools: Final = (
+ cast(list[ChatCompletionToolParam], raw_tools) # cast-ok: token_counter formats any tool dict shape
+ if isinstance(raw_tools, list) and raw_tools
+ else None
+ )
+ system_message: Final = anthropic_system_to_openai_message(extras.get("system"))
+ if messages is not None:
+ counted_messages: Final = (system_message, *messages) if system_message is not None else messages
+ return self._host.invoke("token_counter")(model=tokenizer_model, messages=counted_messages, tools=tools)
+ if input is not None:
+ from openai.types.responses.response_create_params import ResponseInputParam
+
+ from litellm.responses.litellm_completion_transformation.transformation import (
+ LiteLLMCompletionResponsesConfig,
+ )
+
+ typed_input: Final = cast(str | ResponseInputParam, input) # cast-ok: str | list matches transform input
+ input_messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
+ input=typed_input,
+ responses_api_request={"instructions": instructions} if instructions is not None else {},
+ only_since_compaction=True,
+ )
+ return self._host.invoke("token_counter")(
+ model=tokenizer_model,
+ messages=cast(list, input_messages), # cast-ok: transformed chat messages
+ tools=tools,
+ )
+ raise ValueError("Either messages or input must be provided to count tokens")
+
+ def _deployment_max_input_tokens(self, model: str, deployment: Mapping[str, object]) -> int | None:
+ """The deployment's declared context window, or None when it declares none or cannot be resolved."""
+ try:
+ model_info: Final = self._core_get_router_model_info(
+ deployment=cast(dict, deployment), # cast-ok: router deployments are plain dicts
+ received_model_name=model,
+ )
+ except Exception as e: # noqa: BLE001 # best-effort: an unmappable deployment must not hide the others
+ verbose_router_logger.debug(
+ "litellm.router.py::_deployment_max_input_tokens: skipping deployment. Got - %s", e
+ )
+ return None
+ max_input_tokens: Final = model_info.get("max_input_tokens")
+ return max_input_tokens if isinstance(max_input_tokens, int) else None
+
+ def _pre_call_checks_need_token_count(
+ self, model: str, healthy_deployments: Sequence[Mapping[str, object]]
+ ) -> bool:
+ """Whether any healthy deployment declares a context window that a token count could exceed.
+
+ Resolves each deployment the way ``_pre_call_checks`` does, so one unmappable deployment
+ cannot hide a later one that does declare a limit.
+ """
+ return any(
+ self._deployment_max_input_tokens(model, deployment) is not None for deployment in healthy_deployments
+ )
+
+ async def _acount_pre_call_check_tokens(
+ self,
+ model: str,
+ healthy_deployments: Sequence[Mapping[str, object]],
+ messages: Sequence[Mapping[str, str]] | None,
+ input: str | Sequence[object] | None,
+ request_kwargs: Mapping[str, object] | None,
+ ) -> int | None:
+ """Count input tokens off the event loop, so a multi-MB prompt cannot stall the proxy.
+
+ Returns None when no deployment limits its context window, and when counting fails. The
+ caller pairs this with ``skip_inline_token_count`` so neither case puts the count back on
+ the loop: a failed count only skips the context-window check - RPM, region and
+ invalid-params checks still run for every deployment.
+ """
+ if messages is None and input is None:
+ return None
+ try:
+ if not self._pre_call_checks_need_token_count(model, healthy_deployments):
+ return None
+ return await offload_token_count(self._count_pre_call_check_tokens)(
+ messages=cast(list[dict[str, str]] | None, messages), # cast-ok: forwarded to the sync counter
+ input=cast(str | list | None, input), # cast-ok: forwarded to the sync counter
+ request_kwargs=request_kwargs,
+ tokenizer_model=single_encoding_openai_model(
+ self.served_openai_models(model) or served_openai_models(healthy_deployments)
+ )
+ or "",
+ )
+ except Exception as e: # noqa: BLE001 # best-effort: an uncountable prompt must not fail the request
+ verbose_router_logger.error(
+ "litellm.router.py::_acount_pre_call_check_tokens: failed to count tokens. Got - %s", e
+ )
+ return None
+
+ def _resolve_pre_call_check_input_tokens(
+ self,
+ messages: Sequence[Mapping[str, str]] | None,
+ input: str | Sequence[object] | None,
+ request_kwargs: Mapping[str, object] | None,
+ input_token_count: int | None,
+ count_inline: bool,
+ model: str,
+ deployments: Sequence[Mapping[str, object]],
+ ) -> int | None:
+ if input_token_count is not None:
+ return input_token_count
+ if not count_inline:
+ return None
+ try:
+ return self._count_pre_call_check_tokens(
+ messages=cast(list[dict[str, str]] | None, messages), # cast-ok: forwarded to the sync counter
+ input=cast(str | list[object] | None, input), # cast-ok: forwarded to the sync counter
+ request_kwargs=request_kwargs,
+ tokenizer_model=single_encoding_openai_model(
+ self.served_openai_models(model) or served_openai_models(deployments)
+ )
+ or "",
+ )
+ except Exception as e:
+ verbose_router_logger.error(
+ "litellm.router.py::_pre_call_checks: failed to count tokens. "
+ "Skipping the context-window check only; RPM/region/param checks still apply. Got - %s",
+ e,
+ )
+ return None
+
+ @staticmethod
+ def _params_neither_dropped_nor_allowed(params: Mapping[str, object], candidates: frozenset[str]) -> frozenset[str]:
+ if params.get("drop_params") is True:
+ return frozenset()
+ dropped: Final = params.get("additional_drop_params")
+ dropped_names: Final = (
+ frozenset(entry for entry in dropped if isinstance(entry, str))
+ if isinstance(dropped, list)
+ else frozenset()
+ )
+ return candidates - PythonCore._declared_param_allowlist(params) - dropped_names
+
+ @staticmethod
+ def _deployment_lacks_params(deployment: DeploymentTypedDict, group: str, params: frozenset[str]) -> bool:
+ return any(not PythonCore._deployment_supports_param(deployment, group, param) for param in params)
+
+ @staticmethod
+ def _deployment_rejects_params(
+ deployment: DeploymentTypedDict, group: str, params: frozenset[str], request_kwargs: Mapping[str, object]
+ ) -> bool:
+ call_settings: Final[Mapping[str, object]] = {**(deployment.get("litellm_params") or {}), **request_kwargs}
+ return PythonCore._deployment_lacks_params(
+ deployment, group, PythonCore._params_neither_dropped_nor_allowed(call_settings, params)
+ )
+
+ @staticmethod
+ def _deployment_declares_no_vision(deployment: DeploymentTypedDict, group: str) -> bool:
+ plain_deployment: Final = cast(Mapping[str, object], deployment) # cast-ok: router deployments are plain dicts
+ model_info: Final = cast(Mapping[str, object], plain_deployment.get("model_info") or {}) # cast-ok: plain dict
+ declared: Final = model_info.get("supports_vision")
+ if declared is not None:
+ return declared is False
+ raw_params: Final = plain_deployment.get("litellm_params") or {}
+ params: Final = cast(Mapping[str, object], raw_params) # cast-ok: router deployments are plain dicts
+ if not params:
+ return False
+ base_model: Final = model_info.get("base_model") or params.get("base_model")
+ try:
+ model, custom_llm_provider = PythonCore._capability_model_and_provider(deployment["litellm_params"], group)
+ return litellm_utils.is_vision_explicitly_disabled(
+ base_model if isinstance(base_model, str) else model, custom_llm_provider
+ )
+ except Exception as e: # noqa: BLE001 # best-effort filter: an unresolvable provider must not narrow the request
+ verbose_router_logger.debug(
+ "litellm.router.py::_deployment_declares_no_vision: keeping deployment for model=%s. Got - %s", group, e
+ )
+ return False
+
+ @staticmethod
+ def _deployment_serves_route(
+ deployment: DeploymentTypedDict,
+ group: str,
+ resolver: Callable[[str, LlmProviders], object | None],
+ ) -> bool:
+ if "litellm_params" not in deployment:
+ return True
+
+ try:
+ model, resolved_custom_llm_provider = PythonCore._capability_model_and_provider(
+ deployment["litellm_params"], group
+ )
+ provider: Final = next(
+ (member for member in LlmProviders if member.value == resolved_custom_llm_provider),
+ None,
+ )
+ return provider is not None and resolver(model, provider) is not None
+ except Exception as e: # noqa: BLE001 # best-effort capability filter
+ verbose_router_logger.debug(
+ "litellm.router.py::_deployment_serves_route: keeping deployment for model=%s. Got - %s", group, e
+ )
+ return True
+
+ @staticmethod
+ def _without_deployments_that_cannot_serve_route(
+ healthy_deployments: _RouteDeploymentsT, group: str
+ ) -> _RouteDeploymentsT:
+ handler_name: Final = _routed_call_type.get()
+ resolver: Final = _ROUTE_CAPABILITY_RESOLVERS.get(handler_name) if handler_name is not None else None
+ if resolver is None or isinstance(healthy_deployments, Mapping):
+ return healthy_deployments
+
+ filtered_deployments: Final = [
+ deployment
+ for deployment in healthy_deployments
+ if PythonCore._deployment_serves_route(
+ cast(DeploymentTypedDict, deployment), # cast-ok: router candidates use the deployment shape
+ group,
+ resolver,
+ )
+ ]
+ if not filtered_deployments:
+ return healthy_deployments
+ return cast(_RouteDeploymentsT, filtered_deployments) # cast-ok: same list shape as the input candidates
+
+ @staticmethod
+ def _decisions_deployment_refuses_media(
+ deployment: DeploymentTypedDict, group: str, request_kwargs: Mapping[str, object], *, audio: bool, videos: bool
+ ) -> bool:
+ from litellm.decisions.call import deployment_refuses_media
+
+ plain_deployment: Final = cast(Mapping[str, object], deployment) # cast-ok: router deployments are plain dicts
+ model_info: Final = cast(Mapping[str, object], plain_deployment.get("model_info") or {}) # cast-ok: plain dict
+ deployment_params: Final = cast(Mapping[str, object], deployment["litellm_params"]) # cast-ok: plain dict
+ try:
+ model, custom_llm_provider = PythonCore._capability_model_and_provider(deployment["litellm_params"], group)
+ return deployment_refuses_media(
+ model,
+ custom_llm_provider,
+ model_info,
+ {**deployment_params, **request_kwargs},
+ audio=audio,
+ videos=videos,
+ )
+ except Exception as e: # noqa: BLE001 # best-effort filter: a deployment it cannot resolve answers for itself
+ verbose_router_logger.debug(
+ "litellm.router.py::_decisions_deployment_refuses_media: keeping a deployment of model=%s. Got - %s",
+ group,
+ e,
+ )
+ return False
+
+ def _without_decisions_deployments_refusing_media(
+ self, healthy_deployments: _RouteDeploymentsT, group: str, request_kwargs: Mapping[str, object] | None
+ ) -> _RouteDeploymentsT:
+ call_kwargs: Final = request_kwargs or {}
+ audio: Final = bool(call_kwargs.get("audio"))
+ videos: Final = bool(call_kwargs.get("videos"))
+ if (
+ _routed_call_type.get() not in _DECISIONS_MEDIA_CALL_TYPES
+ or not (audio or videos)
+ or isinstance(healthy_deployments, Mapping)
+ ):
+ return healthy_deployments
+
+ sent_kwargs: Final = {**self._active_default_litellm_params(), **call_kwargs}
+ deployments_taking_media: Final = [
+ deployment
+ for deployment in healthy_deployments
+ if not PythonCore._decisions_deployment_refuses_media(
+ cast(DeploymentTypedDict, deployment), # cast-ok: router candidates use the deployment shape
+ group,
+ sent_kwargs,
+ audio=audio,
+ videos=videos,
+ )
+ ]
+ if not deployments_taking_media:
+ return healthy_deployments
+ return cast(_RouteDeploymentsT, deployments_taking_media) # cast-ok: same list shape as the input candidates
+
+ @staticmethod
+ def _without_declared_text_only_deployments(
+ healthy_deployments: _RouteDeploymentsT,
+ group: str,
+ messages: list[dict[str, str]] | None, # mutable-ok: handed to _request_carries_image as-is
+ request_kwargs: Mapping[str, object] | None,
+ ) -> _RouteDeploymentsT:
+ if isinstance(healthy_deployments, Mapping) or not PythonCore._request_carries_image(
+ messages, request_kwargs or {}
+ ):
+ return healthy_deployments
+
+ vision_capable_deployments: Final = [
+ deployment
+ for deployment in healthy_deployments
+ if not PythonCore._deployment_declares_no_vision(
+ cast(DeploymentTypedDict, deployment), # cast-ok: router candidates use the deployment shape
+ group,
+ )
+ ]
+ if not vision_capable_deployments:
+ return healthy_deployments
+ return cast(_RouteDeploymentsT, vision_capable_deployments) # cast-ok: same list shape as the input candidates
+
+ @staticmethod
+ def _request_carries_image(
+ messages: object,
+ request_kwargs: Mapping[str, object],
+ ) -> bool:
+ from litellm.litellm_core_utils.prompt_templates.common_utils import request_contains_image_content
+
+ return request_contains_image_content(messages or request_kwargs.get("messages") or request_kwargs.get("input"))
+
+ @staticmethod
+ def _pre_call_check_requested_chat_params(request_kwargs: Mapping[str, object]) -> frozenset[str]:
+ requested: Final = litellm_utils.get_non_default_params(passed_params=dict(request_kwargs))
+ return frozenset(
+ param
+ for param, value in requested.items()
+ if param in _PRE_CALL_CHECK_FILTERED_PARAMS and not isinstance(value, str)
+ )
+
+ def _pre_call_checks(
+ self,
+ model: str,
+ healthy_deployments: list,
+ messages: list[dict[str, str]] | None = None,
+ input: str | list | None = None,
+ request_kwargs: dict | None = None,
+ input_token_count: int | None = None,
+ skip_inline_token_count: bool = False,
+ request_deployments: Sequence[DeploymentTypedDict] | None = None,
+ ):
+ """
+ Filter out model in model group, if:
+
+ - model context window < message length. For azure openai models, requires 'base_model' is set. - https://docs.litellm.ai/docs/proxy/cost_tracking#spend-tracking-for-azure-openai-models
+ - if region given, filter out models not in that region / unknown region
+ - [TODO] function call and model doesn't support function calling
+ """
+
+ verbose_router_logger.debug("Starting Pre-call checks for deployments in model=%s", model)
+
+ # Optimized: Use list() shallow copy instead of deepcopy
+ # We only pop from the list, not modify deployment dicts - 100x+ faster on hot path (every request)
+ _returned_deployments = list(healthy_deployments)
+
+ invalid_model_indices: Final = set[int]() # Use set for O(1) membership checks
+
+ _context_window_error = False
+ _potential_error_str = ""
+
+ has_countable_input: Final = (messages is not None or input is not None) and not compaction_pending(
+ request_kwargs
+ )
+ deployment_max_input_tokens: Final = tuple(
+ self._deployment_max_input_tokens(model, deployment) for deployment in _returned_deployments
+ )
+ input_tokens: Final = self._resolve_pre_call_check_input_tokens(
+ messages=messages,
+ input=cast(str | list[object] | None, input), # cast-ok: forwarded to the resolver
+ request_kwargs=cast(Mapping[str, object] | None, request_kwargs), # cast-ok: forwarded to the resolver
+ input_token_count=input_token_count,
+ count_inline=has_countable_input
+ and not skip_inline_token_count
+ and any(limit is not None for limit in deployment_max_input_tokens),
+ model=model,
+ deployments=_returned_deployments,
+ )
+ request_settings: Final[Mapping[str, object]] = request_kwargs if request_kwargs is not None else {}
+ requested_params: Final[frozenset[str]] = (
+ self._pre_call_check_requested_chat_params(request_settings)
+ if request_kwargs is not None and self._host.dynamic().drop_params is False
+ else frozenset()
+ )
+
+ for idx, deployment in enumerate(_returned_deployments):
+ # Cache nested dict access to avoid repeated temporary dict allocations
+ _litellm_params = deployment.get("litellm_params", {})
+ _model_info = deployment.get("model_info", {})
+
+ max_input_tokens = deployment_max_input_tokens[idx]
+ if (
+ max_input_tokens is not None
+ and has_countable_input
+ and input_tokens is not None
+ and input_tokens > max_input_tokens
+ ):
+ invalid_model_indices.add(idx)
+ _context_window_error = True
+ deployment_model = (
+ _model_info.get("base_model") or _litellm_params.get("base_model") or _litellm_params.get("model")
+ )
+ _potential_error_str += (
+ f"Model={deployment_model}, Max Input Tokens={max_input_tokens}, Got={input_tokens}"
+ )
+ continue
+
+ ## REGION CHECK ##
+ if request_kwargs is not None and request_kwargs.get("allowed_model_region") is not None:
+ allowed_model_region = request_kwargs.get("allowed_model_region")
+
+ if allowed_model_region is not None:
+ if not is_region_allowed(
+ litellm_params=LiteLLM_Params.model_validate(_litellm_params),
+ allowed_model_region=allowed_model_region,
+ ):
+ invalid_model_indices.add(idx)
+ continue
+
+ typed_deployments: Final = tuple(
+ cast(DeploymentTypedDict, deployment) # cast-ok: router deployments are plain dicts
+ for deployment in _returned_deployments
+ )
+ param_candidates: Final = tuple(
+ (idx, deployment) for idx, deployment in enumerate(typed_deployments) if idx not in invalid_model_indices
+ )
+ candidates_lacking_params: Final = tuple(
+ self._deployment_lacks_params(deployment, model, requested_params) for _, deployment in param_candidates
+ )
+ no_deployment_takes_params: Final = bool(candidates_lacking_params) and all(candidates_lacking_params)
+ candidates_failing_params: Final = (
+ tuple(
+ self._deployment_rejects_params(deployment, model, requested_params, request_settings)
+ for _, deployment in param_candidates
+ )
+ if no_deployment_takes_params
+ else candidates_lacking_params
+ )
+ excluded_indices: Final = frozenset(invalid_model_indices) | frozenset(
+ idx for (idx, _), fails in zip(param_candidates, candidates_failing_params, strict=True) if fails
+ )
+
+ if len(excluded_indices) == len(_returned_deployments):
+ group_deployments: Final = (
+ tuple(request_deployments)
+ if request_deployments is not None
+ else (tuple(self._core_get_all_deployments(model_name=model)) or typed_deployments)
+ )
+ if (
+ not _context_window_error
+ and group_deployments
+ and all(
+ self._deployment_rejects_params(deployment, model, requested_params, request_settings)
+ for deployment in group_deployments
+ )
+ ):
+ raise litellm_exceptions.UnsupportedParamsError(
+ message=(
+ f"no deployment of model={model} supports {sorted(requested_params)}. "
+ "To drop these, set `litellm.drop_params=True` or for proxy `litellm_settings: drop_params: true`, "
+ f"or send allowed_openai_params={sorted(requested_params)} to pass them through"
+ ),
+ model=model,
+ )
+ if _context_window_error is True:
+ raise litellm_exceptions.ContextWindowExceededError(
+ message=f"litellm._pre_call_checks: Context Window exceeded for given call. No models have context window large enough for this call.\n{_potential_error_str}",
+ model=model,
+ llm_provider="",
+ )
+ if len(excluded_indices) > 0:
+ # Single-pass filter using set for O(1) lookups (avoids O(n^2) from repeated pops)
+ _returned_deployments = [d for i, d in enumerate(_returned_deployments) if i not in excluded_indices]
+
+ return _returned_deployments
+
+ def _core_get_model_from_alias(self, model: str) -> str | None:
+ """
+ Get the model from the alias.
+
+ Returns:
+ - str, the litellm model name
+ - None, if model is not in model group alias
+ """
+ return resolve_model_group_alias(self._core_model_group_alias, model)
+
+ def _get_deployment_by_litellm_model(self, model: str) -> list[DeploymentTypedDict]:
+ """
+ Get the deployment by litellm model.
+ """
+ return [m for m in self._core_model_list if m["litellm_params"]["model"] == model]
+
+ def _core_get_routable_deployments(
+ self,
+ model: str,
+ request_kwargs: Mapping[str, object] = MappingProxyType({}),
+ call_type: str | None = None,
+ ) -> tuple[DeploymentTypedDict, ...]:
+ try:
+ with endpoint_selection(call_type, model=model):
+ _, candidates = self._common_checks_available_deployment(model=model, request_kwargs=request_kwargs) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # its signature leaves input and the result untyped
+ except (litellm_exceptions.BadRequestError, ValueError):
+ return ()
+ if isinstance(candidates, dict):
+ return (cast(DeploymentTypedDict, candidates),) # cast-ok: the router returns one deployment as a dict
+ return tuple(cast(list[DeploymentTypedDict], candidates)) # cast-ok: the router returns deployments as a list
+
+ def _try_early_resolve_deployments_for_model_not_in_names(
+ self,
+ model: str,
+ request_team_id: str | None,
+ include_team_models: bool = False,
+ ) -> tuple[str, list | dict] | None:
+ """
+ When ``model`` is not in ``self._core_model_names``, try team routes, pattern routes,
+ team pattern routers, then default deployment. Returns None if none apply.
+ """
+ if model in self._core_model_names:
+ return None
+ # Check for team-specific deployments by team_public_model_name.
+ # This intentionally takes priority over team pattern routers below,
+ # so that named team deployments shadow wildcard/pattern routes.
+ if request_team_id is not None:
+ team_deployments = self._core_get_all_deployments(model_name=model, team_id=request_team_id)
+ if team_deployments:
+ return model, team_deployments
+ elif include_team_models:
+ team_deployments = self._team_deployments_across_teams(model)
+ if team_deployments:
+ return model, team_deployments
+
+ wildcard_deployments: Final = self._core_wildcard_route_deployments(model, request_team_id)
+ if wildcard_deployments:
+ return model, list(wildcard_deployments)
+
+ if self._core_default_deployment is not None:
+ # Shallow copy with nested litellm_params copy (100x+ faster than deepcopy)
+ updated_deployment: Final = self._core_default_deployment.copy()
+ updated_deployment["litellm_params"] = self._core_default_deployment["litellm_params"].copy()
+ updated_deployment["litellm_params"]["model"] = model
+ return model, updated_deployment
+
+ return None
+
+ def _team_deployments_across_teams(self, model: str) -> list[DeploymentTypedDict]:
+ """Every team's deployments under public name `model`, for a proxy admin calling without a team."""
+ with self._deployment_index_lock:
+ team_deployments: Final = [
+ self._core_model_list[index]
+ for (_, public_model_name), indices in self._core_team_model_to_deployment_indices.items()
+ if public_model_name == model
+ for index in indices
+ ]
+ team_ids: Final = {
+ team_id
+ for deployment in team_deployments
+ for team_id in [(deployment.get("model_info") or {}).get("team_id")]
+ if team_id is not None
+ }
+ if len(team_ids) > 1:
+ raise litellm_exceptions.BadRequestError(
+ message=(
+ f"Model name '{model}' matches deployments from multiple teams. "
+ "Specify the deployment ID directly to disambiguate."
+ ),
+ model=model,
+ llm_provider="",
+ )
+ return team_deployments
+
+ def _core_deployments_for_request(
+ self, model: str, request_kwargs: Mapping[str, object]
+ ) -> Sequence[DeploymentTypedDict]:
+ """The deployments `model` names for this caller, through the same alias, then team-first, then
+ global, then admin-across-teams resolution `_common_checks_available_deployment` applies, so
+ strategy selection and compression policy can never disagree with deployment selection about
+ which marker a name means."""
+ registered_name: Final = self._core_get_model_from_alias(model=model) or model
+ team_id: Final = get_request_team_id(request_kwargs)
+ deployments: Final = self._core_get_all_deployments(model_name=registered_name, team_id=team_id)
+ if deployments or team_id is not None or not is_proxy_admin_request(request_kwargs):
+ return deployments
+ return self._team_deployments_across_teams(registered_name)
+
+ @staticmethod
+ def _is_strategy_marker_deployment(deployment: Mapping[str, object]) -> bool:
+ litellm_params: Final = deployment.get("litellm_params")
+ if not isinstance(litellm_params, Mapping):
+ return False
+ deployment_model: Final = litellm_params.get("model")
+ return isinstance(deployment_model, str) and classify_strategy_router_model(deployment_model) is not None
+
+ def _common_checks_available_deployment(
+ self,
+ model: str,
+ messages: list[dict[str, str]] | None = None,
+ input: str | list | None = None,
+ specific_deployment: bool | None = False,
+ request_kwargs: Mapping[str, object] | None = None,
+ ) -> tuple[str, list | dict]:
+ """
+ Common checks for 'get_available_deployment' across sync + async call.
+
+ If 'healthy_deployments' returned is None, this means the user chose a specific deployment
+
+ Returns
+ - str, the litellm model name
+ - List, if multiple models chosen
+ - Dict, if specific model chosen
+ """
+
+ request_team_id: Final = get_request_team_id(request_kwargs)
+ # check if aliases set on litellm model alias map
+ if specific_deployment is True:
+ return model, self._drop_strategy_markers(
+ model,
+ self._filter_reserved_deployments(
+ model=model,
+ healthy_deployments=self._get_deployment_by_litellm_model(model=model),
+ request_team_id=request_team_id,
+ ),
+ )
+ elif model not in self._core_model_names and self._core_has_model_id(model):
+ deployment: Final = self._core_get_deployment(model_id=model)
+ if deployment is not None:
+ deployment_model: Final = deployment.litellm_params.model
+ return deployment_model, cast( # cast-ok: contract requires a plain dict for a single deployment
+ dict,
+ self._filter_reserved_deployments(
+ model=deployment_model,
+ healthy_deployments=(
+ cast( # cast-ok: model_dump of a router deployment
+ DeploymentTypedDict, deployment.model_dump(exclude_none=True)
+ ),
+ ),
+ request_team_id=request_team_id,
+ )[0],
+ )
+ raise ValueError(
+ f"LiteLLM Router: Trying to call specific deployment, but Model ID :{model} does not exist in Model ID map"
+ )
+
+ _model_from_alias: Final = self._core_get_model_from_alias(model=model)
+ if _model_from_alias is not None:
+ model = _model_from_alias
+
+ _routing_group_deployments: Final = self._get_routing_group_deployments(model=model, team_id=request_team_id)
+ if _routing_group_deployments is None:
+ early: Final = self._try_early_resolve_deployments_for_model_not_in_names(
+ model=model,
+ request_team_id=request_team_id,
+ include_team_models=is_proxy_admin_request(request_kwargs),
+ )
+ if early is not None:
+ if not isinstance(early[1], list):
+ return early[0], cast( # cast-ok: contract requires a plain dict for a single deployment
+ dict,
+ self._filter_reserved_deployments(
+ model=early[0],
+ healthy_deployments=(
+ cast( # cast-ok: early resolve returns a router deployment
+ DeploymentTypedDict, early[1]
+ ),
+ ),
+ request_team_id=request_team_id,
+ )[0],
+ )
+ return early[0], self._drop_strategy_markers(
+ early[0],
+ self._filter_reserved_deployments(
+ model=early[0],
+ healthy_deployments=early[1],
+ request_team_id=request_team_id,
+ ),
+ )
+
+ ## get healthy deployments
+ ### get all deployments
+ healthy_deployments = (
+ _routing_group_deployments
+ if _routing_group_deployments is not None
+ else self._core_get_all_deployments(model_name=model, team_id=request_team_id)
+ )
+ _pre_model_access_group_filter_len: Final = len(healthy_deployments)
+ healthy_deployments = self._filter_reserved_deployments(
+ model=model,
+ healthy_deployments=self._filter_deployments_by_model_access_groups(
+ model=model,
+ healthy_deployments=healthy_deployments,
+ request_kwargs=request_kwargs,
+ request_team_id=request_team_id,
+ ),
+ request_team_id=request_team_id,
+ )
+ _access_group_filter_emptied_candidates = (
+ _pre_model_access_group_filter_len > 0 and len(healthy_deployments) == 0
+ )
+
+ if len(healthy_deployments) == 0:
+ # check if the user sent in a deployment name instead
+ # Do not fall back when access-group filtering removed every candidate;
+ # _get_deployment_by_litellm_model does not re-apply that filter.
+ if _pre_model_access_group_filter_len == 0:
+ _litellm_model_deployments: Final = self._get_deployment_by_litellm_model(model=model)
+ healthy_deployments = self._filter_reserved_deployments(
+ model=model,
+ healthy_deployments=self._filter_deployments_by_model_access_groups(
+ model=model,
+ healthy_deployments=_litellm_model_deployments,
+ request_kwargs=request_kwargs,
+ request_team_id=request_team_id,
+ ),
+ request_team_id=request_team_id,
+ )
+ # If the litellm-model lookup produced candidates that access-group
+ # filtering then removed, treat this the same as the by-name path
+ # being emptied: prevent default-model fallback from bypassing the
+ # restriction (the fallback model may have no access_groups and
+ # would short-circuit the filter).
+ if len(_litellm_model_deployments) > 0 and len(healthy_deployments) == 0:
+ _access_group_filter_emptied_candidates = True
+
+ if verbose_router_logger.isEnabledFor(logging.DEBUG):
+ verbose_router_logger.debug("initial list of deployments: %s", healthy_deployments)
+
+ if len(healthy_deployments) == 0:
+ # Check for default fallbacks if no deployments are found for the requested model
+ # Do not fall back to another model when access-group filtering removed every
+ # candidate for the requested name: re-filtering the fallback model can be a
+ # no-op when it has no access_groups, incorrectly serving a different model.
+ if self._has_default_fallbacks() and not _access_group_filter_emptied_candidates:
+ fallback_model: Final = self._get_first_default_fallback()
+ if fallback_model:
+ verbose_router_logger.info(
+ "Model '%s' not found. Attempting to use default fallback model '%s'.", model, fallback_model
+ )
+ # Re-assign model to the fallback and try to get deployments again
+ model = fallback_model
+ healthy_deployments = self._core_get_all_deployments(model_name=model, team_id=request_team_id)
+ healthy_deployments = self._filter_reserved_deployments(
+ model=model,
+ healthy_deployments=self._filter_deployments_by_model_access_groups(
+ model=model,
+ healthy_deployments=healthy_deployments,
+ request_kwargs=request_kwargs,
+ request_team_id=request_team_id,
+ ),
+ request_team_id=request_team_id,
+ )
+
+ # If still no deployments after checking for fallbacks, raise an error
+ if len(healthy_deployments) == 0:
+ raise litellm_exceptions.BadRequestError(
+ message=f"You passed in model={model}. {RouterErrors.no_healthy_deployments.value}",
+ model=model,
+ llm_provider="",
+ )
+
+ if self._host.dynamic().model_alias_map and model in self._host.dynamic().model_alias_map:
+ model = self._host.dynamic().model_alias_map[
+ model
+ ] # update the model to the actual value if an alias has been passed in
+
+ return model, self._drop_strategy_markers(model, healthy_deployments)
+
+ def _drop_strategy_markers(
+ self, model: str, deployments: Sequence[DeploymentTypedDict]
+ ) -> list[DeploymentTypedDict]:
+ """A strategy marker is never a callable deployment, whichever resolution arm produced it."""
+ selectable: Final = [d for d in deployments if not self._is_strategy_marker_deployment(d)]
+ if deployments and not selectable:
+ raise litellm_exceptions.BadRequestError(
+ message=f"You passed in model={model}. {RouterErrors.only_strategy_marker_deployments.value}",
+ model=model,
+ llm_provider="",
+ )
+ eligible: Final = eligible_deployments(model, selectable)
+ if isinstance(eligible, tuple):
+ return list(eligible)
+ raise eligible
+
+ def _filter_reserved_deployments(
+ self,
+ model: str,
+ healthy_deployments: Sequence[DeploymentTypedDict],
+ request_team_id: str | None,
+ ) -> tuple[DeploymentTypedDict, ...]:
+ result: Final = filter_reserved_deployments(
+ self._drop_strategy_markers(model, healthy_deployments), request_team_id, now=datetime.now(timezone.utc)
+ )
+ if result.blocking_window is not None and len(result.deployments) == 0:
+ raise litellm_exceptions.BadRequestError(
+ message=f"Deployment {model} is reserved for another team until "
+ f"{result.blocking_window.end:%H:%M} {result.blocking_window.timezone}",
+ model=model,
+ llm_provider="",
+ )
+ shared: Final = filter_ptu_shared_deployments(result.deployments, request_team_id)
+ if shared.withheld and len(shared.deployments) == 0:
+ raise litellm_exceptions.BadRequestError(
+ message=f"Deployment {model} is reserved for the teams holding a PTU share of it",
+ model=model,
+ llm_provider="",
+ )
+ return shared.deployments
+
+ def _filter_deployments_by_model_access_groups(
+ self,
+ model: str,
+ healthy_deployments: list[DeploymentTypedDict],
+ request_kwargs: Mapping[str, object] | None,
+ request_team_id: str | None,
+ ) -> list[DeploymentTypedDict]:
+ """
+ Restrict candidate deployments to caller-authorized model access groups.
+
+ This is only applied when:
+ - request metadata includes `user_api_key_auth`, and
+ - caller permissions for this model are access-group-only
+ (no explicit model, wildcard, or all-proxy grants).
+ """
+ if not healthy_deployments or request_kwargs is None:
+ return healthy_deployments
+
+ user_api_key_auth: Final = request_user_api_key_auth(request_kwargs)
+ if user_api_key_auth is None:
+ return healthy_deployments
+
+ object_models: Final = set(getattr(user_api_key_auth, "models", []) or [])
+ object_team_models: Final = set(getattr(user_api_key_auth, "team_models", []) or [])
+ allowed_models: Final = object_models | object_team_models
+ if not allowed_models:
+ return healthy_deployments
+
+ # If caller has direct model/wildcard/all-proxy access, do not constrain
+ # deployment choice by access group.
+ if model in allowed_models or "*" in allowed_models or "all-proxy-models" in allowed_models:
+ return healthy_deployments
+
+ access_groups_for_model: Final = self._core_get_model_access_groups(model_name=model, team_id=request_team_id)
+ if len(access_groups_for_model) == 0:
+ return healthy_deployments
+
+ allowed_access_groups: Final = set(access_groups_for_model.keys()) & allowed_models
+ if not allowed_access_groups:
+ # No overlap means this request was not authorized via model access
+ # group membership for this model, so do not force group filtering.
+ return healthy_deployments
+
+ filtered_deployments: Final = []
+ for deployment in healthy_deployments:
+ deployment_model_info = deployment.get("model_info") or {}
+ deployment_access_groups = set(deployment_model_info.get("access_groups", []) or [])
+ if deployment_access_groups & allowed_access_groups:
+ filtered_deployments.append(deployment)
+
+ return filtered_deployments
+
+ async def _core_async_get_healthy_deployments(
+ self,
+ model: str,
+ request_kwargs: dict,
+ messages: list[dict[str, str]] | None = None,
+ input: str | list | None = None,
+ specific_deployment: bool | None = False,
+ parent_otel_span: Span | None = None,
+ health_check_probe: bool = False,
+ ) -> list[dict] | dict:
+ """
+ Get the healthy deployments for a model.
+
+ Returns:
+ - List[Dict], if multiple models chosen
+ *OR*
+ - Dict, if specific model chosen
+ """
+
+ model, healthy_deployments = self._common_checks_available_deployment(
+ model=model,
+ messages=messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ request_kwargs=request_kwargs,
+ )
+
+ # IF TEAM ID SPECIFIED ON MODEL, AND REQUEST CONTAINS USER_API_KEY_TEAM_ID, FILTER OUT MODELS THAT ARE NOT IN THE TEAM
+ ## THIS PREVENTS WRITING FILES OF OTHER TEAMS TO MODELS THAT ARE TEAM-ONLY MODELS
+ healthy_deployments = filter_team_based_models(
+ healthy_deployments=healthy_deployments,
+ request_kwargs=request_kwargs,
+ )
+
+ if verbose_router_logger.isEnabledFor(logging.DEBUG):
+ verbose_router_logger.debug("healthy_deployments after team filter: %s", healthy_deployments)
+
+ healthy_deployments = filter_web_search_deployments(
+ healthy_deployments=healthy_deployments,
+ request_kwargs=request_kwargs,
+ )
+
+ if verbose_router_logger.isEnabledFor(logging.DEBUG):
+ verbose_router_logger.debug("healthy_deployments after web search filter: %s", healthy_deployments)
+
+ healthy_deployments = ( # pyright: ignore[reportUnknownVariableType] # rebind-ok: route filter narrows
+ self._without_deployments_that_cannot_serve_route(
+ healthy_deployments, # pyright: ignore[reportUnknownArgumentType] # legacy selector type
+ model,
+ )
+ )
+
+ if isinstance(healthy_deployments, dict):
+ if (healthy_deployments.get("model_info") or {}).get("blocked") is True:
+ raise litellm_exceptions.ServiceUnavailableError(
+ message=f"Model '{model}' is currently paused and cannot accept requests.",
+ model=model,
+ llm_provider="",
+ )
+ return healthy_deployments
+
+ request_deployments: Final = cast( # cast-ok: router deployments are plain dicts
+ Sequence[DeploymentTypedDict], healthy_deployments
+ )
+
+ # Health-check-based filtering (before cooldown)
+ healthy_deployments = ( # rebind-ok: filtered candidates enter health checks
+ await self._async_filter_health_check_unhealthy_deployments(
+ healthy_deployments=healthy_deployments,
+ parent_otel_span=parent_otel_span,
+ health_check_probe=health_check_probe,
+ )
+ )
+
+ routing_read_batch: Final = RoutingReadBatch.active()
+ cooldown_deployments: Final = (
+ await async_get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
+ if routing_read_batch is None
+ else await routing_read_batch.async_get_cooldown_deployments(
+ ctx=self._core_ctx,
+ healthy_deployments=healthy_deployments,
+ parent_otel_span=parent_otel_span,
+ )
+ )
+ if verbose_router_logger.isEnabledFor(logging.DEBUG):
+ verbose_router_logger.debug("cooldown deployments: %s", cooldown_deployments)
+ _pre_cooldown_deployments: Final = healthy_deployments
+ healthy_deployments = self._filter_cooldown_deployments(
+ healthy_deployments=healthy_deployments,
+ cooldown_deployments=cooldown_deployments,
+ )
+ # Safety net: only bypass cooldown filter when health-check routing is
+ # driving cooldown (i.e. allowed_fails_policy is set). Without a policy,
+ # cooldowns are from real request failures and must not be bypassed.
+ if (
+ not healthy_deployments
+ and self._core_enable_health_check_routing
+ and self._core_allowed_fails_policy is not None
+ ):
+ verbose_router_logger.warning(
+ "All deployments in cooldown via health-check routing, bypassing cooldown filter"
+ )
+ healthy_deployments = _pre_cooldown_deployments
+
+ healthy_deployments = self._filter_blocked_deployments(healthy_deployments)
+
+ healthy_deployments = await self.async_callback_filter_deployments(
+ model=model,
+ healthy_deployments=healthy_deployments,
+ messages=(cast(list[AllMessageValues], messages) if messages is not None else None),
+ request_kwargs=request_kwargs,
+ parent_otel_span=parent_otel_span,
+ )
+
+ if self._core_enable_pre_call_checks and (messages is not None or input is not None):
+ deployments_to_check: Final = healthy_deployments
+ healthy_deployments = self._pre_call_checks(
+ model=model,
+ healthy_deployments=deployments_to_check,
+ messages=messages,
+ input=input,
+ request_kwargs=request_kwargs,
+ input_token_count=await self._acount_pre_call_check_tokens(
+ model=model,
+ healthy_deployments=deployments_to_check,
+ messages=messages,
+ input=input,
+ request_kwargs=request_kwargs,
+ ),
+ skip_inline_token_count=True,
+ request_deployments=request_deployments,
+ )
+ # check if user wants to do tag based routing
+ healthy_deployments = await get_deployments_for_tag(
+ ctx=self._core_ctx,
+ model=model,
+ request_kwargs=request_kwargs,
+ healthy_deployments=healthy_deployments,
+ metadata_variable_name=self._get_metadata_variable_name_from_kwargs(request_kwargs),
+ request_deployments=request_deployments,
+ )
+
+ # narrow to whatever `self._core_routing_plugins` left in candidate_models
+ healthy_deployments = self._filter_by_routing_plugin_candidates(
+ healthy_deployments=healthy_deployments,
+ request_kwargs=request_kwargs,
+ )
+
+ ## ORDER FILTERING ## -> if user set 'order' in deployments, return deployments with lowest order (e.g. order=1 > order=2)
+ _target_order: Final = (request_kwargs or {}).pop("_target_order", None)
+ batch_candidates: Final = self._without_decisions_deployments_refusing_media(
+ self._without_declared_text_only_deployments(
+ cast(list[dict[str, object]], healthy_deployments), # cast-ok: router deployments are plain dicts
+ model,
+ messages,
+ cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict
+ ),
+ model,
+ cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict
+ )
+ embedding_batch_admitting: Final = deployments_admitting_embedding_batch(
+ batch_candidates,
+ _OPAQUE_VALUE_ADAPTER.validate_python(input),
+ _OPAQUE_VALUE_ADAPTER.validate_python(request_kwargs),
+ )
+ embedding_batch_routed: Final = (
+ embedding_batch_admitting
+ if embedding_batch_admitting is not None
+ else deployments_when_none_admits_embedding_batch(
+ batch_candidates,
+ cast(list[dict[str, object]], _pre_cooldown_deployments), # cast-ok: router deployments are plain dicts
+ await self._async_routable_cooled_deployment_ids(
+ model=model,
+ request_deployments=request_deployments,
+ cooldown_deployments=cooldown_deployments,
+ request_kwargs=cast(Mapping[str, object], request_kwargs), # cast-ok: str-keyed request kwargs
+ has_request_input=True,
+ ),
+ _OPAQUE_VALUE_ADAPTER.validate_python(input),
+ _OPAQUE_VALUE_ADAPTER.validate_python(request_kwargs),
+ )
+ )
+ healthy_deployments = litellm_utils.get_order_filtered_deployments(
+ list(
+ self._narrow_to_video_deployment(
+ without_deployments_that_cannot_batch(
+ embedding_batch_routed, caller_deployments=lambda: batch_candidates
+ )
+ )
+ ),
+ target_order=_target_order,
+ )
+
+ ## WEIGHTED FAILOVER EXCLUSION ## -> drop deployments already tried in
+ ## this request via weighted-failover. Always honored, regardless of the
+ ## router-level flag, so a stale exclusion key on kwargs cannot escape.
+ _excluded_deployment_ids: Final = (request_kwargs or {}).pop("_excluded_deployment_ids", None)
+ healthy_deployments = litellm_utils.get_excluded_filtered_deployments(
+ cast(list[dict], healthy_deployments),
+ excluded_deployment_ids=_excluded_deployment_ids,
+ )
+
+ ## RETRY SKIP ## -> drop deployments that already refused this request with a
+ ## non-retryable status, unless that leaves nothing, so the caller still gets
+ ## the provider's own error instead of a no-deployments error.
+ _retry_skipped_deployment_ids: Final = as_retry_skipped_deployment_ids(
+ request_kwargs.pop("_retry_skipped_deployment_ids", None) if request_kwargs else None
+ )
+ healthy_deployments = (
+ litellm_utils.get_excluded_filtered_deployments(
+ healthy_deployments, excluded_deployment_ids=_retry_skipped_deployment_ids
+ )
+ or healthy_deployments
+ )
+
+ if len(healthy_deployments) == 0:
+ exception: Final = await async_raise_no_deployment_exception(
+ ctx=self._core_ctx,
+ model=model,
+ parent_otel_span=parent_otel_span,
+ route_model_ids=await self._async_routable_cooled_deployment_ids(
+ model=model,
+ request_deployments=request_deployments,
+ cooldown_deployments=cooldown_deployments,
+ request_kwargs=cast(Mapping[str, object], request_kwargs), # cast-ok: str-keyed request kwargs
+ has_request_input=messages is not None or input is not None,
+ ),
+ )
+ raise exception
+
+ return healthy_deployments
+
+ @staticmethod
+ def _pop_effort_from_nested_carrier(request_kwargs: dict[str, object], carrier: str) -> None:
+ nested: Final = request_kwargs.get(carrier)
+ if not isinstance(nested, Mapping):
+ return
+ sanitized: Final = {key: value for key, value in nested.items() if key != "effort"}
+ if sanitized:
+ request_kwargs[carrier] = sanitized # rebind-ok: copy-on-write, so a shared nested carrier is never edited
+ else:
+ request_kwargs.pop(carrier, None)
+
+ @staticmethod
+ def _tier_ceiling_under_the_surface_name(
+ tier_litellm_params: Mapping[str, object], responses_call: bool
+ ) -> Mapping[str, object]:
+ """``max_tokens``, ``max_completion_tokens`` and ``max_output_tokens`` are one
+ ceiling under three names, and each surface reads exactly one of them: the
+ Responses bridge builds its internal ``max_tokens`` from ``max_output_tokens``
+ and would overwrite the tier's, chat and /v1/messages never read
+ ``max_output_tokens``, and litellm already renames ``max_tokens`` to
+ ``max_completion_tokens`` for the OpenAI models that require it. Collapse
+ whatever the tier carries onto the surface's own name, preferring a value the
+ operator already wrote under that name."""
+ surface_key: Final = "max_output_tokens" if responses_call else "max_tokens"
+ carried: Final = tuple(
+ key
+ for key in (surface_key, "max_tokens", "max_completion_tokens", "max_output_tokens")
+ if key in tier_litellm_params
+ )
+ if not carried:
+ return tier_litellm_params
+ return MappingProxyType(
+ {
+ **{k: v for k, v in tier_litellm_params.items() if k not in OUTPUT_TOKEN_CEILING_PARAMS},
+ surface_key: tier_litellm_params[carried[0]],
+ }
+ )
+
+ def _pin_tier_params_onto_request(
+ self,
+ model: str,
+ tier_litellm_params: Mapping[str, object] | None,
+ request_kwargs: dict,
+ responses_call: bool,
+ ) -> bool:
+ """Apply per-tier litellm_params and report whether they pinned an output ceiling."""
+ if not tier_litellm_params:
+ return False
+ reject_api_key_secret_reference(tier_litellm_params)
+ accepted_tier_params: Final = self._tier_params_the_target_accepts(model, tier_litellm_params, request_kwargs)
+ surface_tier_params: Final = self._tier_ceiling_under_the_surface_name(
+ accepted_tier_params, responses_call=responses_call
+ )
+ self._drop_client_carriers_a_tier_pin_supersedes(request_kwargs, surface_tier_params)
+ request_kwargs.update(surface_tier_params)
+ return not OUTPUT_TOKEN_CEILING_PARAMS.isdisjoint(surface_tier_params)
+
+ @staticmethod
+ def _restore_client_output_ceiling(request_kwargs: MutableMapping[str, object]) -> None:
+ """Restore caller intent before routing, so neither a previous tier pin nor alias defaults
+ become the next attempt's budget. The proxy strips caller-written stamps at ingestion."""
+ callers_ceiling: Final = stamped_client_ceiling(request_kwargs)
+ if callers_ceiling is None:
+ return
+ for carrier in OUTPUT_TOKEN_CEILING_PARAMS:
+ request_kwargs.pop(carrier, None)
+ request_kwargs.update(callers_ceiling)
+
+ @staticmethod
+ def _stamp_client_output_ceiling(
+ request_kwargs: dict[str, object], # mutable-ok: preserve request-owned metadata across routing attempts
+ ) -> None:
+ _, metadata_bucket = get_or_create_metadata_bucket(request_kwargs)
+ metadata_bucket.setdefault(
+ CLIENT_OUTPUT_CEILING_METADATA_KEY,
+ {carrier: request_kwargs[carrier] for carrier in OUTPUT_TOKEN_CEILING_PARAMS if carrier in request_kwargs},
+ )
+
+ @staticmethod
+ def _drop_client_carriers_a_tier_pin_supersedes(
+ request_kwargs: dict[str, object],
+ tier_litellm_params: Mapping[str, object],
+ ) -> None:
+ """Tier litellm_params are deliberate operator overrides, but provider
+ translations let a caller-supplied carrier of the same setting
+ (``thinking``, ``output_config.effort``, ``reasoning.effort``) outrank
+ the ``reasoning_effort`` alias, so a pinned effort only reaches the wire
+ if the client's other encodings are removed before the merge. Non-effort
+ fields a carrier also holds (``output_config.format``,
+ ``reasoning.summary``) are kept. An output ceiling has the same shape:
+ ``max_tokens``, ``max_completion_tokens`` and ``max_output_tokens`` are
+ one setting under three names, and a provider handed two of them either
+ rejects the request or picks one by iteration order."""
+ if not OUTPUT_TOKEN_CEILING_PARAMS.isdisjoint(tier_litellm_params):
+ PythonCore._stamp_client_output_ceiling(request_kwargs)
+ for carrier in OUTPUT_TOKEN_CEILING_PARAMS:
+ request_kwargs.pop(carrier, None)
+ if "reasoning_effort" not in tier_litellm_params:
+ return
+ request_kwargs.pop("thinking", None)
+ PythonCore._pop_effort_from_nested_carrier(request_kwargs, "output_config")
+ PythonCore._pop_effort_from_nested_carrier(request_kwargs, "reasoning")
+
+ async def _async_get_available_deployment_unresolved( # noqa: ANN202 # legacy selector result remains inferred
+ self,
+ model: str,
+ request_kwargs: dict,
+ messages: list[dict[str, str]] | None = None,
+ input: str | list | None = None,
+ specific_deployment: bool | None = False,
+ ):
+ """
+ Async implementation of 'get_available_deployments'.
+
+ Allows all cache calls to be made async => 10x perf impact (8rps -> 100 rps).
+ """
+ with phase_span(f"route {model}"):
+ return await self._async_get_available_deployment(
+ model=model,
+ request_kwargs=request_kwargs,
+ messages=messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ )
+
+ async def _async_get_available_deployment(
+ self,
+ model: str,
+ request_kwargs: dict,
+ messages: list[dict[str, str]] | None,
+ input: str | list | None,
+ specific_deployment: bool | None,
+ ):
+ if (
+ self._core_routing_strategy != "usage-based-routing-v2"
+ and self._core_routing_strategy != "simple-shuffle"
+ and self._core_routing_strategy != "cost-based-routing"
+ and self._core_routing_strategy != "latency-based-routing"
+ and self._core_routing_strategy != "least-busy"
+ ): # prevent regressions for other routing strategies, that don't have async get available deployments implemented.
+ return self._get_available_deployment_unresolved(
+ model=model,
+ messages=messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ request_kwargs=request_kwargs,
+ )
+ try:
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(request_kwargs)
+
+ #########################################################
+ # Execute Pre-Routing Hooks
+ # this hook can modify the model, messages before the routing decision is made
+ #########################################################
+ responses_call: Final = input is not None and messages is None
+ pre_routing_hook_response: Final = await self._core_async_pre_routing_hook(
+ model=model,
+ request_kwargs=request_kwargs,
+ messages=messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ )
+ if pre_routing_hook_response is not None:
+ model = pre_routing_hook_response.model
+ messages = pre_routing_hook_response.messages
+ record_pre_routing_selection(request_kwargs, model)
+ self._pin_tier_params_onto_request(
+ model=model,
+ tier_litellm_params=pre_routing_hook_response.litellm_params if pre_routing_hook_response else None,
+ request_kwargs=request_kwargs,
+ responses_call=responses_call,
+ )
+ #########################################################
+
+ # Resolve the strategy and logger AFTER the pre-routing hook, since
+ # the hook can replace `model` and routing-group lookup must key
+ # off the final model name.
+ strategy, strategy_selector = self._get_routing_context(model, request_kwargs)
+ pick_attributes: Final = _deployment_pick_attributes(
+ model, _ROUTING_KWARGS_ADAPTER.validate_python(request_kwargs)
+ )
+ routing_read_batch: Final = RoutingReadBatch.for_strategy(strategy, strategy_selector)
+
+ with RoutingReadBatch.scoped(routing_read_batch):
+ healthy_deployments: Final = await self._core_async_get_healthy_deployments(
+ model=model,
+ request_kwargs=request_kwargs,
+ messages=messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ parent_otel_span=parent_otel_span,
+ )
+ if isinstance(healthy_deployments, dict):
+ await self._async_override_selector_pre_call_check(
+ strategy, strategy_selector, healthy_deployments, parent_otel_span
+ )
+ phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
+ return healthy_deployments
+
+ # When encrypted content affinity pins to a specific deployment,
+ if request_kwargs.get("_encrypted_content_affinity_pinned") and len(healthy_deployments) == 1:
+ await self._async_override_selector_pre_call_check(
+ strategy, strategy_selector, healthy_deployments[0], parent_otel_span
+ )
+ phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
+ return healthy_deployments[0]
+
+ start_time: Final = time.time()
+ if strategy == "simple-shuffle":
+ shuffled: Final = simple_shuffle(
+ resolve_model_alias=self._core_get_model_from_alias,
+ healthy_deployments=healthy_deployments,
+ model=model,
+ request_kwargs=request_kwargs,
+ )
+ phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
+ return shuffled
+ with PrefetchedUsage.scoped(
+ routing_read_batch.prefetched_usage if routing_read_batch is not None else None
+ ):
+ deployment: Final = await self._select_deployment_async(
+ strategy=strategy,
+ selector=strategy_selector,
+ model=model,
+ healthy_deployments=healthy_deployments,
+ messages=messages,
+ input=input,
+ request_kwargs=request_kwargs,
+ )
+ if deployment is None:
+ exception: Final = await async_raise_no_deployment_exception(
+ ctx=self._core_ctx,
+ model=model,
+ parent_otel_span=parent_otel_span,
+ )
+ raise exception
+ await self._async_override_selector_pre_call_check(
+ strategy, strategy_selector, deployment, parent_otel_span
+ )
+ verbose_router_logger.info(
+ "get_available_deployment for model: %s, Selected deployment: %s for model: %s",
+ model,
+ self.print_deployment(deployment),
+ model,
+ )
+
+ end_time: Final = time.time()
+ _duration: Final = end_time - start_time
+ asyncio.create_task(
+ self.service_logger_obj.async_service_success_hook(
+ service=ServiceTypes.ROUTER,
+ duration=_duration,
+ call_type=".async_get_available_deployments",
+ parent_otel_span=parent_otel_span,
+ start_time=start_time,
+ end_time=end_time,
+ )
+ )
+
+ phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
+ return deployment
+ except Exception as e:
+ mark_deployment_selection_failure(e)
+ traceback_exception: Final = traceback.format_exc()
+ # if router rejects call -> log to langfuse/otel/etc.
+ if request_kwargs is not None:
+ logging_obj: Final = request_kwargs.get("litellm_logging_obj", None)
+
+ if logging_obj is not None:
+ asyncio.create_task(
+ logging_obj.dispatch_failure_handlers(
+ exception=e,
+ traceback_exception=traceback_exception,
+ prefer_async_handlers=True,
+ )
+ )
+ raise e
+
+ _core_async_get_available_deployment = ( # pyright: ignore[reportUnknownVariableType] # legacy selector return
+ async_resolve_pods_after(
+ _async_get_available_deployment_unresolved # pyright: ignore[reportUnknownArgumentType] # legacy type
+ )
+ )
+
+ async def async_get_available_deployment_for_pass_through(
+ self,
+ model: str,
+ request_kwargs: dict,
+ messages: list[dict[str, str]] | None = None,
+ input: str | list | None = None,
+ specific_deployment: bool | None = False,
+ ):
+ """
+ Async version of get_available_deployment_for_pass_through
+
+ Only returns deployments configured with use_in_pass_through=True
+ """
+ with phase_span(f"route {model}"):
+ return await self._async_get_available_deployment_for_pass_through(
+ model=model,
+ request_kwargs=request_kwargs,
+ messages=messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ )
+
+ async def _async_get_available_deployment_for_pass_through(
+ self,
+ model: str,
+ request_kwargs: dict,
+ messages: list[dict[str, str]] | None,
+ input: str | list | None,
+ specific_deployment: bool | None,
+ ):
+ try:
+ parent_otel_span: Final = get_parent_otel_span_from_kwargs(request_kwargs)
+
+ # 1. Execute pre-routing hook
+ responses_call: Final = input is not None and messages is None
+ pre_routing_hook_response: Final = await self._core_async_pre_routing_hook(
+ model=model,
+ request_kwargs=request_kwargs,
+ messages=messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ )
+ if pre_routing_hook_response is not None:
+ model = pre_routing_hook_response.model
+ messages = pre_routing_hook_response.messages
+ record_pre_routing_selection(request_kwargs, model)
+ self._pin_tier_params_onto_request(
+ model=model,
+ tier_litellm_params=pre_routing_hook_response.litellm_params if pre_routing_hook_response else None,
+ request_kwargs=request_kwargs,
+ responses_call=responses_call,
+ )
+
+ # 2. Get healthy deployments
+ healthy_deployments: Final = await self._core_async_get_healthy_deployments(
+ model=model,
+ request_kwargs=request_kwargs,
+ messages=messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ parent_otel_span=parent_otel_span,
+ )
+
+ strategy, strategy_selector = self._get_routing_context(model, request_kwargs)
+
+ # 3. If specific deployment returned, verify if it supports pass-through
+ if isinstance(healthy_deployments, dict):
+ if (healthy_deployments.get("model_info") or {}).get("blocked") is True:
+ raise litellm_exceptions.ServiceUnavailableError(
+ message=f"Model '{model}' is currently paused and cannot accept requests.",
+ model=model,
+ llm_provider="",
+ )
+ litellm_params: Final = healthy_deployments.get("litellm_params", {})
+ if litellm_params.get("use_in_pass_through"):
+ await self._async_override_selector_pre_call_check(
+ strategy, strategy_selector, healthy_deployments, parent_otel_span
+ )
+ return healthy_deployments
+ else:
+ raise litellm_exceptions.BadRequestError(
+ message=f"Deployment {healthy_deployments.get('model_info', {}).get('id')} does not support pass-through endpoint (use_in_pass_through=False)",
+ model=model,
+ llm_provider="",
+ )
+
+ # 4. Filter deployments that support pass-through
+ pass_through_deployments = self._filter_pass_through_deployments(healthy_deployments=healthy_deployments)
+
+ if len(pass_through_deployments) == 0:
+ raise litellm_exceptions.BadRequestError(
+ message=f"Model {model} has no deployments configured with use_in_pass_through=True. Please add use_in_pass_through: true to the deployment configuration",
+ model=model,
+ llm_provider="",
+ )
+
+ # 5. Apply load balancing strategy
+ start_time: Final = time.perf_counter()
+ if strategy == "simple-shuffle":
+ return simple_shuffle(
+ resolve_model_alias=self._core_get_model_from_alias,
+ healthy_deployments=pass_through_deployments,
+ model=model,
+ request_kwargs=request_kwargs,
+ )
+ deployment: Final = await self._select_deployment_async(
+ strategy=strategy,
+ selector=strategy_selector,
+ model=model,
+ healthy_deployments=pass_through_deployments,
+ messages=messages,
+ input=input,
+ request_kwargs=request_kwargs,
+ )
+
+ if deployment is None:
+ exception: Final = await async_raise_no_deployment_exception(
+ ctx=self._core_ctx,
+ model=model,
+ parent_otel_span=parent_otel_span,
+ )
+ raise exception
+ await self._async_override_selector_pre_call_check(
+ strategy, strategy_selector, deployment, parent_otel_span
+ )
+
+ verbose_router_logger.info(
+ "async_get_available_deployment_for_pass_through model: %s, selected deployment: %s",
+ model,
+ self.print_deployment(deployment),
+ )
+
+ end_time: Final = time.perf_counter()
+ _duration: Final = end_time - start_time
+ asyncio.create_task(
+ self.service_logger_obj.async_service_success_hook(
+ service=ServiceTypes.ROUTER,
+ duration=_duration,
+ call_type=".async_get_available_deployments",
+ parent_otel_span=parent_otel_span,
+ start_time=start_time,
+ end_time=end_time,
+ )
+ )
+
+ return deployment
+ except Exception as e:
+ mark_deployment_selection_failure(e)
+ traceback_exception: Final = traceback.format_exc()
+ if request_kwargs is not None:
+ logging_obj: Final = request_kwargs.get("litellm_logging_obj", None)
+ if logging_obj is not None:
+ asyncio.create_task(
+ logging_obj.dispatch_failure_handlers(
+ exception=e,
+ traceback_exception=traceback_exception,
+ prefer_async_handlers=True,
+ )
+ )
+ raise e
+
+ async def _run_routing_plugins(
+ self,
+ model: str,
+ request_kwargs: dict,
+ messages: list[dict[str, object]] | None,
+ ) -> RoutingContext:
+ """
+ Build a RoutingContext for `model`, run it through `self._core_routing_plugins`
+ in order, then stash the narrowed candidate list and accumulated signals
+ onto `request_kwargs["metadata"]` so `_filter_by_routing_plugin_candidates`
+ (called later, during healthy-deployment filtering) can consume them.
+ """
+ from litellm.litellm_core_utils.prompt_templates.factory import (
+ resolve_structured_messages,
+ )
+
+ candidate_models: Final = list(self._core_resolved_litellm_models(model))
+
+ metadata_key: Final = self._get_metadata_variable_name_from_kwargs(request_kwargs)
+ metadata: Final = request_kwargs.setdefault(metadata_key, {})
+
+ context = RoutingContext(
+ raw_messages=messages or [],
+ structured_messages=resolve_structured_messages(messages=messages, request_kwargs=request_kwargs) or [],
+ candidate_models=candidate_models,
+ metadata=metadata,
+ )
+
+ for plugin in self._core_routing_plugins:
+ context = await plugin.run(context)
+
+ metadata["routing_plugin_signals"] = context.signals
+ if len(context.candidate_models) < len(candidate_models):
+ metadata["_routing_plugin_candidate_models"] = context.candidate_models
+
+ return context
+
+ def _filter_by_routing_plugin_candidates(
+ self,
+ healthy_deployments: list[dict] | dict,
+ request_kwargs: dict,
+ ) -> list[dict] | dict:
+ """
+ Narrow `healthy_deployments` to whatever `self._core_routing_plugins` left in
+ `context.candidate_models`. Raises rather than silently falling back to
+ the unfiltered pool -- a plugin narrowing to nothing is a policy decision
+ (e.g. no model this tenant's budget allows), not something to bypass.
+ """
+ if not self._core_routing_plugins or not isinstance(healthy_deployments, list):
+ return healthy_deployments
+
+ metadata_key: Final = self._get_metadata_variable_name_from_kwargs(request_kwargs)
+ candidate_models: Final = (request_kwargs.get(metadata_key) or {}).get("_routing_plugin_candidate_models")
+ # `is None` (not falsy-check): a plugin narrowing to an empty list must
+ # still hit the "no deployments left" raise below, not be treated the
+ # same as "no plugin ever set this key".
+ if candidate_models is None:
+ return healthy_deployments
+
+ candidate_set: Final = set(candidate_models)
+ filtered: Final = [d for d in healthy_deployments if d.get("litellm_params", {}).get("model") in candidate_set]
+
+ if not filtered:
+ raise ValueError(f"No deployments left after routing-plugin filtering. candidate_models={candidate_models}")
+
+ return filtered
+
+ def _select_pre_routing_strategy(
+ self, model: str, request_kwargs: Mapping[str, object]
+ ) -> "TaggedPreRoutingStrategy[PreRoutingStrategy] | None":
+ """
+ Resolve the pre-routing strategy for `model`, disambiguating deployments
+ that share a `model_name` by matching the request's tags against each
+ registered strategy's tags before falling back to the first registered.
+ Returns the tagged registry entry so the caller can tell whether the
+ request's tags were what selected it, and can locate the marker
+ deployment the strategy was registered from via its (model_name, tags)
+ pair.
+
+ The registries are keyed by the marker deployment's own `model_name`, which
+ for a team-scoped router is the internal `model_name_{team}_{uuid}` while
+ the caller sends the team's public name. So the names looked up are the
+ `model_name`s of whatever deployments this caller's request resolves `model`
+ to, and `model` itself when it resolves to none.
+
+ With tag filtering enabled, router-wide or by the request's
+ enable_tag_filtering (which the proxy sets from key/team
+ router_settings), strategies that all carry real tags matching none of
+ the request's do not capture it when the name also has plain
+ deployments: returning None hands the request to ordinary tag-aware
+ deployment selection.
+ """
+ registries: Final = (
+ self._core_auto_routers,
+ self._core_complexity_routers,
+ self._core_adaptive_routers,
+ self._core_quality_routers,
+ )
+ if not any(registries):
+ return None
+ deployments: Final = self._core_deployments_for_request(model, request_kwargs)
+ registered_names: Final = tuple(dict.fromkeys(str(d["model_name"]) for d in deployments)) or (model,)
+ candidates: Final = tuple(
+ tagged for registry in registries for name in registered_names for tagged in registry.get(name, [])
+ )
+ if not candidates:
+ return None
+
+ request_tags: Final = get_routing_tags_from_request_kwargs(request_kwargs)
+ if request_tags:
+ for tagged in candidates:
+ if tagged.tags and is_valid_deployment_tag(
+ list(tagged.tags), request_tags, self._core_tag_filtering_match_any
+ ):
+ return tagged
+ for tagged in candidates:
+ if "default" in tagged.tags:
+ return tagged
+ request_scoped_filtering: Final = request_kwargs.get("enable_tag_filtering") is True
+ if (
+ (self._core_enable_tag_filtering or request_scoped_filtering)
+ and all(tagged.tags for tagged in candidates)
+ and any(not self._is_strategy_marker_deployment(d) for d in deployments)
+ ):
+ return None
+ return candidates[0]
+
+ @staticmethod
+ def _request_header(request_kwargs: Mapping[str, object], header_name: str) -> str | None:
+ proxy_server_request: Final = request_kwargs.get("proxy_server_request")
+ if not isinstance(proxy_server_request, Mapping):
+ return None
+ headers: Final = proxy_server_request.get("headers")
+ if not isinstance(headers, Mapping):
+ return None
+ return next(
+ (
+ value
+ for key, value in headers.items()
+ if isinstance(key, str) and key.lower() == header_name and isinstance(value, str)
+ ),
+ None,
+ )
+
+ def _claude_code_session_router_cache_key(self, request_kwargs: Mapping[str, object]) -> str | None:
+ session_id: Final = self._request_header(request_kwargs, "x-claude-code-session-id")
+ if session_id is None or _CLAUDE_CODE_SESSION_ID_RE.fullmatch(session_id) is None:
+ return None
+ metadata_name: Final = "litellm_metadata" if "litellm_metadata" in request_kwargs else "metadata"
+ metadata: Final = request_kwargs.get(metadata_name)
+ if not isinstance(metadata, Mapping):
+ return None
+ caller_scope: Final = metadata.get("user_api_key_hash")
+ if not isinstance(caller_scope, str) or not caller_scope:
+ return None
+ return f"claude_code_session_router:v1:{caller_scope}:{session_id}"
+
+ @with_service_target(CLAUDE_CODE_SESSION_ROUTER_BINDING_TARGET)
+ async def _delete_claude_code_session_router_binding(self, cache_key: str) -> None:
+ try:
+ await self._claude_code_session_router_cache.async_delete_cache(key=cache_key)
+ except Exception as e: # noqa: BLE001 # cache cleanup must not fail an otherwise routable request
+ verbose_router_logger.warning(
+ "Failed to delete Claude Code session router binding; the binding may remain until its TTL expires: %s",
+ e,
+ )
+
+ @with_service_target(CLAUDE_CODE_SESSION_ROUTER_BINDING_TARGET)
+ async def _get_claude_code_session_router_binding(self, cache_key: str) -> object:
+ session_cache: Final = self._claude_code_session_router_cache
+ try:
+ if session_cache.redis_cache is None:
+ return await session_cache.async_get_cache(key=cache_key)
+ return await session_cache.redis_cache.async_get_cache(key=cache_key)
+ except Exception as e: # noqa: BLE001 # an optional binding must not make routing depend on Redis
+ log_redis_failure(
+ verbose_router_logger,
+ logging.WARNING,
+ "Failed to read Claude Code session router binding; using the requested model",
+ e,
+ )
+ return None
+
+ @with_service_target(CLAUDE_CODE_SESSION_ROUTER_BINDING_TARGET)
+ async def _resolve_claude_code_session_router(
+ self,
+ model: str,
+ registered_model_name: str,
+ request_kwargs: Mapping[str, object],
+ ) -> str:
+ if is_native_compaction_call():
+ return registered_model_name
+ if not any(
+ (
+ self._core_auto_routers,
+ self._core_complexity_routers,
+ self._core_adaptive_routers,
+ self._core_quality_routers,
+ )
+ ):
+ return registered_model_name
+ cache_key: Final = self._claude_code_session_router_cache_key(request_kwargs)
+ if cache_key is None or not isinstance(request_kwargs, dict):
+ return registered_model_name
+ if request_kwargs.get("fallback_depth") not in (None, 0):
+ return registered_model_name
+
+ agent_id: Final = self._request_header(request_kwargs, "x-claude-code-agent-id")
+ if agent_id is not None:
+ bound_model: Final = await self._get_claude_code_session_router_binding(cache_key)
+ if not isinstance(bound_model, str):
+ return registered_model_name
+ bound_registered_model: Final = self._core_get_model_from_alias(model=bound_model) or bound_model
+ if self._select_pre_routing_strategy(bound_registered_model, request_kwargs) is None:
+ await self._delete_claude_code_session_router_binding(cache_key)
+ return registered_model_name
+ await self._claude_code_session_router_cache.async_set_cache(
+ key=cache_key,
+ value=bound_model,
+ ttl=_CLAUDE_CODE_SESSION_ROUTER_TTL_SECONDS,
+ )
+ self._stamp_or_clear_metadata_key(request_kwargs, "model_group", bound_model)
+ return bound_registered_model
+
+ if self._select_pre_routing_strategy(registered_model_name, request_kwargs) is None:
+ return registered_model_name
+ await self._claude_code_session_router_cache.async_set_cache(
+ key=cache_key,
+ value=model,
+ ttl=_CLAUDE_CODE_SESSION_ROUTER_TTL_SECONDS,
+ )
+ return registered_model_name
+
+ async def _core_async_pre_routing_hook(
+ self,
+ model: str,
+ request_kwargs: dict[str, object],
+ messages: list[dict[str, Any]] | None = None,
+ input: str | list | None = None,
+ specific_deployment: bool | None = False,
+ ) -> PreRoutingHookResponse | None:
+ """
+ This hook is called before the routing decision is made.
+
+ Used for the litellm auto-router to modify the request before the routing decision is made.
+
+ `model` is whatever the caller asked for, which may be a `model_group_alias` key or a team's
+ public model name, while the strategy registries and the marker deployment are keyed by the
+ marker's own `model_name`, so every lookup below resolves the alias first and the team name
+ through the deployment path. Only the lookups: the caller-facing name stays the alias, since
+ spend metadata is stamped before routing and the response carries the tier group the
+ strategy picked.
+ """
+ self._restore_client_output_ceiling(request_kwargs)
+ self._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None)
+ requested_registered_model_name: Final = self._core_get_model_from_alias(model=model) or model
+ registered_model_name: Final = await self._resolve_claude_code_session_router(
+ model=model,
+ registered_model_name=requested_registered_model_name,
+ request_kwargs=request_kwargs,
+ )
+
+ #########################################################
+ # Run the routing-plugin pipeline, if any plugins are configured.
+ # Plugins narrow the candidate deployment pool (consumed later by
+ # `_filter_by_routing_plugin_candidates`) and may attach signals for
+ # downstream strategies (auto-router, complexity-router, ...) to read.
+ #########################################################
+ if self._core_routing_plugins:
+ await self._run_routing_plugins(
+ model=registered_model_name, request_kwargs=request_kwargs, messages=messages
+ )
+
+ selected_strategy: Final = self._select_pre_routing_strategy(
+ model=registered_model_name, request_kwargs=request_kwargs
+ )
+ if selected_strategy is None:
+ await arm_compaction(request_kwargs, None)
+ self._stamp_or_clear_metadata_key(
+ request_kwargs=request_kwargs, key=SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY, value=None
+ )
+ self._stamp_or_clear_metadata_key(
+ request_kwargs=request_kwargs, key=CONSUMED_REQUEST_TAGS_METADATA_KEY, value=None
+ )
+ return None
+
+ from litellm.router_strategy.complexity_router.complexity_router import ComplexityRouter
+
+ marker: Final = self._selected_strategy_marker_deployment(
+ model=registered_model_name, strategy_tags=selected_strategy.tags, request_kwargs=request_kwargs
+ )
+ definition: Final = selected_strategy.deployment
+ config_id: Final = definition.model_info.id if definition else None
+ updated_at: Final = definition.model_info.updated_at if definition else None
+ router_kind: Final = classify_strategy_router_model(definition.litellm_params.model) if definition else None
+ provenance: Final[StandardLoggingRoutingDecision] = {
+ "router_model_name": definition.model_name if definition else registered_model_name,
+ **({"router_type": router_kind} if router_kind is not None else {}),
+ **({"router_config_id": config_id} if config_id is not None else {}),
+ **({"router_config_updated_at": updated_at.isoformat()} if updated_at is not None else {}),
+ **(
+ {"router_config_fingerprint": selected_strategy.definition_fingerprint}
+ if selected_strategy.definition_fingerprint is not None
+ else {}
+ ),
+ }
+ phase_attributes(routing_decision_attributes(provenance))
+
+ reject_recursive_compactor(registered_model_name)
+ await arm_compaction(
+ request_kwargs,
+ selected_strategy.strategy.config.context_compaction
+ if isinstance(selected_strategy.strategy, ComplexityRouter)
+ else None,
+ tuple(
+ dict.fromkeys(
+ member
+ for pool in selected_strategy.strategy.config.tiers.values()
+ for member in ((pool,) if isinstance(pool, str) else pool)
+ )
+ )
+ if isinstance(selected_strategy.strategy, ComplexityRouter)
+ else (),
+ parent_model=model,
+ ctx=self._core_ctx,
+ allow_escalation=isinstance(selected_strategy.strategy, ComplexityRouter)
+ and selected_strategy.strategy.config.enable_context_window_escalation,
+ messages=messages,
+ )
+
+ await router_host().authorize_strategy_inference(
+ deployment=marker,
+ request_kwargs=request_kwargs,
+ llm_router=self._core_public(),
+ )
+
+ # Same tag-aware lookup the proxy's pre-call arming used, so an alias with
+ # several tag-scoped markers cannot suppress under one and route under another.
+ compression_policy: Final = router_host().compression_policy(
+ llm_router=self._core_public(),
+ model_alias=registered_model_name,
+ request_kwargs=request_kwargs,
+ request_tags=get_routing_tags_from_request_kwargs(request_kwargs),
+ )
+ # Shared compression already ran in the pre-call hook, so reuse it rather than
+ # compressing twice. Conditional on arming having actually happened: only the
+ # proxy arms, and on the SDK path the shortcut would skip both hops entirely.
+ needs_independent_routing_compression: Final = compression_policy is not None and not (
+ compression_policy.is_same
+ and compression_policy.model is not None
+ and router_host().model_hop_compression_armed()
+ )
+ routing_messages: Final = (
+ await router_host().messages_for_routing(
+ policy=compression_policy, messages=messages, request_kwargs=request_kwargs
+ )
+ if needs_independent_routing_compression
+ else None
+ )
+
+ routed: Final = await selected_strategy.strategy.async_pre_routing_hook(
+ model=registered_model_name,
+ request_kwargs=request_kwargs,
+ messages=routing_messages if routing_messages is not None else messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ )
+ traced_routed: Final = (
+ routed.model_copy(update={"routing_decision": {**provenance, **routed.routing_decision}})
+ if routed is not None and routed.routing_decision is not None
+ else routed
+ )
+ # Routing-only compression must not leak into the response: the model call and
+ # deployment-context filtering key off this field. Compared by value, since
+ # pydantic rebuilds the list rather than keeping the object passed in.
+ pre_routing_hook_response: Final = (
+ traced_routed.model_copy(update={"messages": messages})
+ if traced_routed is not None and routing_messages is not None and traced_routed.messages == routing_messages
+ else traced_routed
+ )
+ self._record_routing_decision(
+ request_kwargs=request_kwargs,
+ routing_decision=(pre_routing_hook_response.routing_decision if pre_routing_hook_response else None),
+ )
+ self._stamp_or_clear_metadata_key(
+ request_kwargs=request_kwargs,
+ key=SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
+ value=(pre_routing_hook_response.session_affinity_ttl_seconds if pre_routing_hook_response else None),
+ )
+ self._stamp_or_clear_metadata_key(
+ request_kwargs=request_kwargs,
+ key=CONSUMED_REQUEST_TAGS_METADATA_KEY,
+ value=self._consumed_request_tags_stamp(
+ selected_strategy=selected_strategy,
+ pre_routing_hook_response=pre_routing_hook_response,
+ request_tags=get_routing_tags_from_request_kwargs(request_kwargs),
+ ),
+ )
+
+ # `model` (the alias, e.g. "smart-router") is never the deployment actually
+ # called - apply the router marker's own litellm_params to the request,
+ # since the tier/route deployment the hook selected won't have them. The
+ # marker entry is looked up by its `auto_router/` model prefix and the
+ # selected strategy's tags, never by list position: plain deployments may
+ # share the alias `model_name` and must not leak their params (`api_base`,
+ # `api_key`, ...) onto the routed call. Router-only fields (tpm, rpm,
+ # weight, complexity_router_config, ...) are excluded from the actual
+ # outbound LLM call downstream by litellm.types.utils.all_litellm_params,
+ # not here. Custom pricing fields ARE call params, so they must be
+ # excluded here: they price the alias, not the deployment the hook
+ # selected, and forwarding them re-registers the routed deployment at
+ # the alias's price (an explicit 0 makes every alias request bill $0).
+ # Forwarded params only fill gaps: the keys inserted here ride along on the
+ # request (top level, so sibling requests sharing a `metadata` dict never see
+ # them) until `_update_kwargs_with_deployment` drops any the selected
+ # deployment sets itself (its own `aws_region_name` beats the marker's).
+ # Per-tier `litellm_params` on the hook response are deliberate overrides
+ # the caller applies on top, so those keys and equivalent output-limit
+ # names are never tracked as alias defaults here.
+ marker_params: Final = (
+ self._forwardable_alias_marker_params(
+ model=registered_model_name, strategy_tags=selected_strategy.tags, request_kwargs=request_kwargs
+ )
+ if pre_routing_hook_response is not None
+ else ()
+ )
+ tier_param_keys: Final = (
+ tuple(pre_routing_hook_response.litellm_params or ()) if pre_routing_hook_response is not None else ()
+ )
+ overridden_marker_keys: Final = frozenset(tier_param_keys) | (
+ OUTPUT_TOKEN_CEILING_PARAMS if not OUTPUT_TOKEN_CEILING_PARAMS.isdisjoint(tier_param_keys) else frozenset()
+ )
+ newly_forwarded: Final = tuple(
+ (key, value)
+ for key, value in marker_params
+ if key not in request_kwargs and key not in overridden_marker_keys
+ )
+ if pre_routing_hook_response is not None:
+ self._stamp_client_output_ceiling(request_kwargs)
+ request_kwargs.pop(_ALIAS_MARKER_FORWARDED_PARAMS_KWARG, None)
+ request_kwargs.update(newly_forwarded)
+ if newly_forwarded:
+ request_kwargs.update(((_ALIAS_MARKER_FORWARDED_PARAMS_KWARG, tuple(key for key, _ in newly_forwarded)),))
+
+ return pre_routing_hook_response
+
+ def _selected_strategy_marker_deployment(
+ self, model: str, strategy_tags: tuple[str, ...], request_kwargs: Mapping[str, object]
+ ) -> DeploymentTypedDict | None:
+ markers: Final = tuple(
+ deployment
+ for deployment in self._core_deployments_for_request(model, request_kwargs)
+ if "model" in deployment["litellm_params"]
+ and str(deployment["litellm_params"]["model"]).startswith(AUTO_ROUTER_MODEL_PREFIX)
+ )
+ tag_matched: Final = tuple(
+ deployment
+ for deployment in markers
+ if (tuple(deployment["litellm_params"]["tags"] or ()) if "tags" in deployment["litellm_params"] else ())
+ == strategy_tags
+ )
+ return tag_matched[0] if tag_matched else (markers[0] if markers else None)
+
+ def _forwardable_alias_marker_params(
+ self, model: str, strategy_tags: tuple[str, ...], request_kwargs: Mapping[str, object]
+ ) -> tuple[tuple[str, object], ...]:
+ marker: Final = self._selected_strategy_marker_deployment(
+ model=model, strategy_tags=strategy_tags, request_kwargs=request_kwargs
+ )
+ if marker is None:
+ return ()
+ return tuple(
+ (key, value)
+ for key, value in marker["litellm_params"].items()
+ if key not in _ALIAS_PARAMS_NEVER_FORWARDED
+ and key not in CustomPricingLiteLLMParams.model_fields
+ and value is not None
+ )
+
+ @staticmethod
+ def _forwarded_alias_marker_keys_the_deployment_sets(
+ deployment: Mapping[str, object], forwarded_keys: object
+ ) -> tuple[str, ...]:
+ deployment_litellm_params: Final = deployment.get("litellm_params")
+ if not isinstance(deployment_litellm_params, Mapping) or not isinstance(forwarded_keys, tuple):
+ return ()
+ return tuple(
+ key
+ for key in forwarded_keys
+ if isinstance(key, str) and PythonCore._deployment_sets_litellm_param(deployment_litellm_params, key)
+ )
+
+ @staticmethod
+ def _deployment_sets_litellm_param(deployment_litellm_params: Mapping[str, object], key: str) -> bool:
+ value: Final = deployment_litellm_params.get(key)
+ if value is None:
+ return False
+ field: Final = LiteLLM_Params.model_fields.get(key)
+ return field is None or value != field.default
+
+ def _consumed_request_tags_stamp(
+ self,
+ selected_strategy: "TaggedPreRoutingStrategy[PreRoutingStrategy]",
+ pre_routing_hook_response: PreRoutingHookResponse | None,
+ request_tags: Sequence[str],
+ ) -> ConsumedRequestTagsStamp | None:
+ """Record which tags picked the router and which model group it rewrote to, or None.
+
+ A request whose tags matched the selected strategy's tags has already spent those
+ tags on picking the router; re-applying them to the routed tier's model group would
+ empty the pool unless every tier deployment repeats the marker's tag. Only the
+ strategy's own tags are spent: the request's other tags keep constraining
+ deployment selection inside the routed group, and key/team policy tags are
+ untouched because tag filtering separately re-applies whatever
+ `metadata.inherited_tags` carries for the stamped group.
+ """
+ if pre_routing_hook_response is None or not selected_strategy.tags or not request_tags:
+ return None
+ if not is_valid_deployment_tag(selected_strategy.tags, request_tags, self._core_tag_filtering_match_any):
+ return None
+ return ConsumedRequestTagsStamp(model_group=pre_routing_hook_response.model, tags=selected_strategy.tags)
+
+ @staticmethod
+ def _record_routing_decision(
+ request_kwargs: dict,
+ routing_decision: StandardLoggingRoutingDecision | None,
+ ) -> None:
+ """Make the request's metadata describe THIS routing attempt, and only this one.
+
+ Fallbacks re-enter the hook with the same `request_kwargs`, so an attempt that
+ picks a plain model group after an auto-router group failed must clear the
+ earlier decision; leaving it would attribute the first router's tier and cause
+ to the deployment that actually served the request. Every attempt therefore
+ writes or clears, never just writes.
+ """
+ from litellm.router_strategy.auto_router.baseline_request import capture_baseline_parameters
+ from litellm.types.router import BaselineRouteStamp
+
+ phase_attributes(routing_decision_attributes(routing_decision))
+ baseline_model: Final = routing_decision.get("savings_baseline_model") if routing_decision else None
+ baseline_id: Final = routing_decision.get("savings_baseline_deployment_id") if routing_decision else None
+ router_name: Final = routing_decision.get("router_model_name") if routing_decision else None
+ caller_parameters: Final = (
+ capture_baseline_parameters(request_kwargs) if router_name and baseline_model else None
+ )
+ PythonCore._stamp_or_clear_metadata_key(
+ request_kwargs=request_kwargs,
+ key="_autorouter_baseline_route",
+ value=(
+ BaselineRouteStamp(router_name, baseline_model, baseline_id, caller_parameters)
+ if router_name and baseline_model and baseline_id
+ else None
+ ),
+ )
+ PythonCore._stamp_or_clear_metadata_key(
+ request_kwargs=request_kwargs,
+ key="routing_decision",
+ value=(
+ None
+ if routing_decision is None
+ else PythonCore._redact_prompt_text_if_needed(
+ request_kwargs=request_kwargs, routing_decision=routing_decision
+ )
+ ),
+ )
+
+ @staticmethod
+ def _stamp_or_clear_metadata_key(request_kwargs: dict, key: str, value: object | None) -> None:
+ """Write a proxy-internal metadata key for THIS routing attempt, or clear it.
+
+ Fallbacks and retries re-enter the pre-routing hook with the same
+ `request_kwargs`, so every attempt must write or clear, never just write;
+ a value left behind by an earlier attempt would be attributed to this one.
+ `get_or_create_metadata_bucket` is the single owner of "which dict holds
+ proxy-internal metadata": it picks `litellm_metadata` when present (so the
+ value never lands in the `metadata` dict that routes like /v1/messages
+ forward to the provider) and replaces a non-dict value rather than silently
+ skipping the write. Clearing pops from BOTH buckets so a request whose
+ bucket resolution changed between attempts cannot resurrect a stale value.
+ """
+ if value is None:
+ for bucket in (request_kwargs.get("metadata"), request_kwargs.get("litellm_metadata")):
+ if isinstance(bucket, dict):
+ bucket.pop(key, None)
+ return
+
+ _, metadata_bucket = get_or_create_metadata_bucket(request_kwargs)
+ metadata_bucket[key] = value
+
+ @staticmethod
+ def _redact_prompt_text_if_needed(
+ request_kwargs: Mapping[str, object],
+ routing_decision: StandardLoggingRoutingDecision,
+ ) -> StandardLoggingRoutingDecision:
+ """Drop verbatim prompt text from the record when message logging is redacted.
+
+ An operator who turns message logging off has said prompt content must not reach
+ the logs, so the fields that quote the prompt (the matched keywords, and the
+ signals that name them) are omitted. Derived values are kept, because a tier, a
+ cause, a score or an escalation flag aggregates the prompt rather than
+ reproducing any of it, and dropping them would leave the row unexplainable for
+ no privacy gain. Applied here rather than in each strategy so a strategy added
+ later cannot bypass it.
+ """
+ from litellm.litellm_core_utils.redact_messages import (
+ should_redact_message_logging,
+ )
+
+ if not should_redact_message_logging(
+ {
+ "litellm_params": request_kwargs,
+ "standard_callback_dynamic_params": request_kwargs.get("standard_callback_dynamic_params"),
+ }
+ ):
+ return routing_decision
+ kept: Final = {
+ field: value
+ for field, value in routing_decision.items()
+ if field not in PROMPT_QUOTING_ROUTING_DECISION_FIELDS
+ }
+ return cast(StandardLoggingRoutingDecision, kept) # cast-ok: dropping optional keys preserves the type
+
+ def _get_available_deployment_unresolved( # noqa: ANN202 # legacy selector result remains inferred
+ self,
+ model: str,
+ messages: list[dict[str, str]] | None = None,
+ input: str | list | None = None,
+ specific_deployment: bool | None = False,
+ request_kwargs: dict | None = None,
+ ):
+ """
+ Returns the deployment based on routing strategy
+ """
+ if self._core_routing_plugins:
+ raise ValueError(
+ "Router(plugins=[...]) is configured, but this call resolved to the synchronous "
+ "deployment-selection path, which never runs the routing-plugin pipeline. This "
+ "happens for sync Router methods (e.g. Router.completion()) and for async calls "
+ "with a routing_strategy that has no async-native selector (e.g. legacy "
+ "'usage-based-routing', v1). Silently skipping "
+ "configured plugins would let a policy plugin (e.g. a deny-all rule) be bypassed. "
+ "Use an async Router method with a supported routing_strategy (simple-shuffle, "
+ "usage-based-routing-v2, cost-based-routing, latency-based-routing, least-busy), "
+ "or remove `plugins` from the Router config."
+ )
+ # users need to explicitly call a specific deployment, by setting `specific_deployment = True` as completion()/embedding() kwarg
+ # When this was no explicit we had several issues with fallbacks timing out
+
+ model, healthy_deployments = self._common_checks_available_deployment(
+ model=model,
+ messages=messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ request_kwargs=request_kwargs,
+ )
+ healthy_deployments = ( # pyright: ignore[reportUnknownVariableType] # rebind-ok: route filter narrows
+ self._without_deployments_that_cannot_serve_route(
+ healthy_deployments, # pyright: ignore[reportUnknownArgumentType] # legacy selector type
+ model,
+ )
+ )
+ strategy, strategy_selector = self._get_routing_context(model, request_kwargs)
+ routing_kwargs: Final = _ROUTING_KWARGS_ADAPTER.validate_python(request_kwargs)
+ pick_attributes: Final = _deployment_pick_attributes(model, routing_kwargs)
+
+ if isinstance(healthy_deployments, dict):
+ if (healthy_deployments.get("model_info") or {}).get("blocked") is True:
+ raise litellm_exceptions.ServiceUnavailableError(
+ message=f"Model '{model}' is currently paused and cannot accept requests.",
+ model=model,
+ llm_provider="",
+ )
+ self._override_selector_pre_call_check(strategy, strategy_selector, healthy_deployments)
+ phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
+ return healthy_deployments
+
+ parent_otel_span: Final[Span | None] = get_parent_otel_span_from_kwargs(request_kwargs)
+ request_deployments: Final = cast( # cast-ok: router deployments are plain dicts
+ Sequence[DeploymentTypedDict], healthy_deployments
+ )
+
+ # Health-check-based filtering (before cooldown)
+ healthy_deployments = self._filter_health_check_unhealthy_deployments(
+ healthy_deployments=healthy_deployments,
+ parent_otel_span=parent_otel_span,
+ )
+
+ cooldown_deployments: Final = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
+ _pre_cooldown_deployments: Final = healthy_deployments
+ healthy_deployments = self._filter_cooldown_deployments(
+ healthy_deployments=healthy_deployments,
+ cooldown_deployments=cooldown_deployments,
+ )
+ if (
+ not healthy_deployments
+ and self._core_enable_health_check_routing
+ and self._core_allowed_fails_policy is not None
+ ):
+ verbose_router_logger.warning(
+ "All deployments in cooldown via health-check routing, bypassing cooldown filter"
+ )
+ healthy_deployments = _pre_cooldown_deployments
+
+ healthy_deployments = continuation_deployments(
+ self._filter_blocked_deployments(healthy_deployments), routing_kwargs
+ )
+
+ # filter pre-call checks
+ if self._core_enable_pre_call_checks and (messages is not None or input is not None):
+ healthy_deployments = self._pre_call_checks(
+ model=model,
+ healthy_deployments=healthy_deployments,
+ messages=messages,
+ input=input,
+ request_kwargs=request_kwargs,
+ request_deployments=request_deployments,
+ )
+
+ ## ORDER FILTERING ## -> if user set 'order' in deployments, return deployments with lowest order (e.g. order=1 > order=2)
+ _target_order: Final = (request_kwargs or {}).pop("_target_order", None)
+ batch_candidates: Final = self._without_decisions_deployments_refusing_media(
+ self._without_declared_text_only_deployments(
+ cast(list[dict[str, object]], healthy_deployments), # cast-ok: router deployments are plain dicts
+ model,
+ messages,
+ cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict
+ ),
+ model,
+ cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict
+ )
+ embedding_batch_admitting: Final = deployments_admitting_embedding_batch(
+ batch_candidates,
+ _OPAQUE_VALUE_ADAPTER.validate_python(input),
+ routing_kwargs,
+ )
+ embedding_batch_routed: Final = (
+ embedding_batch_admitting
+ if embedding_batch_admitting is not None
+ else deployments_when_none_admits_embedding_batch(
+ batch_candidates,
+ cast(list[dict[str, object]], _pre_cooldown_deployments), # cast-ok: router deployments are plain dicts
+ self._deployment_ids(
+ self._region_routable_deployments(
+ request_deployments,
+ cast(Mapping[str, object] | None, request_kwargs), # cast-ok: str-keyed request kwargs
+ True,
+ )
+ )
+ & frozenset(cooldown_deployments),
+ _OPAQUE_VALUE_ADAPTER.validate_python(input),
+ routing_kwargs,
+ )
+ )
+ healthy_deployments = litellm_utils.get_order_filtered_deployments(
+ list(
+ self._narrow_to_video_deployment(
+ without_deployments_that_cannot_batch(
+ embedding_batch_routed,
+ caller_deployments=lambda: deployments_the_caller_may_use(batch_candidates, routing_kwargs),
+ )
+ )
+ ),
+ target_order=_target_order,
+ )
+
+ ## WEIGHTED FAILOVER EXCLUSION ## -> drop deployments already tried in
+ ## this request via weighted-failover. See async counterpart in
+ ## async_get_healthy_deployments for details.
+ _excluded_deployment_ids: Final = (request_kwargs or {}).pop("_excluded_deployment_ids", None)
+ healthy_deployments = litellm_utils.get_excluded_filtered_deployments(
+ healthy_deployments,
+ excluded_deployment_ids=_excluded_deployment_ids,
+ )
+
+ ## RETRY SKIP ## -> see async counterpart in async_get_healthy_deployments.
+ _retry_skipped_deployment_ids: Final = as_retry_skipped_deployment_ids(
+ request_kwargs.pop("_retry_skipped_deployment_ids", None) if request_kwargs else None
+ )
+ healthy_deployments = (
+ litellm_utils.get_excluded_filtered_deployments(
+ healthy_deployments, excluded_deployment_ids=_retry_skipped_deployment_ids
+ )
+ or healthy_deployments
+ )
+
+ if len(healthy_deployments) == 0:
+ model_ids = self._core_get_model_ids(model_name=model)
+ _cooldown_time = self._core_cooldown_cache.get_min_cooldown(
+ model_ids=model_ids, parent_otel_span=parent_otel_span
+ )
+ _cooldown_list = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
+ raise RouterRateLimitError(
+ model=model,
+ cooldown_time=_cooldown_time,
+ enable_pre_call_checks=self._core_enable_pre_call_checks,
+ cooldown_list=_cooldown_list,
+ model_ids=model_ids,
+ cooldown_deployments=get_cooldown_deployment_info(
+ self._core_ctx,
+ self._deployment_ids(
+ self._region_routable_deployments(
+ request_deployments,
+ cast(Mapping[str, object] | None, request_kwargs), # cast-ok: str-keyed request kwargs
+ messages is not None or input is not None,
+ )
+ ),
+ _cooldown_list,
+ ),
+ )
+
+ if strategy == "simple-shuffle":
+ # if users pass rpm or tpm, we do a random weighted pick - based on rpm/tpm
+ ############## Check 'weight' param set for weighted pick #################
+ shuffled: Final = simple_shuffle(
+ resolve_model_alias=self._core_get_model_from_alias,
+ healthy_deployments=healthy_deployments,
+ model=model,
+ request_kwargs=request_kwargs,
+ )
+ phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
+ return shuffled
+ deployment: Final = self._select_deployment_sync(
+ strategy=strategy,
+ selector=strategy_selector,
+ model=model,
+ healthy_deployments=healthy_deployments,
+ messages=messages,
+ input=input,
+ request_kwargs=request_kwargs,
+ )
+
+ if deployment is None:
+ verbose_router_logger.info("get_available_deployment for model: %s, No deployment available", model)
+ model_ids = self._core_get_model_ids(model_name=model)
+ _cooldown_time = self._core_cooldown_cache.get_min_cooldown(
+ model_ids=model_ids, parent_otel_span=parent_otel_span
+ )
+ _cooldown_list = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
+ raise RouterRateLimitError(
+ model=model,
+ cooldown_time=_cooldown_time,
+ enable_pre_call_checks=self._core_enable_pre_call_checks,
+ cooldown_list=_cooldown_list,
+ model_ids=model_ids,
+ )
+ self._override_selector_pre_call_check(strategy, strategy_selector, deployment)
+ verbose_router_logger.info(
+ "get_available_deployment for model: %s, Selected deployment: %s for model: %s",
+ model,
+ self.print_deployment(deployment),
+ model,
+ )
+ phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
+ return deployment
+
+ get_available_deployment = ( # pyright: ignore[reportUnknownVariableType] # legacy selector return
+ resolve_pods_after(
+ _get_available_deployment_unresolved # pyright: ignore[reportUnknownArgumentType] # legacy annotations
+ )
+ )
+
+ def _core_get_available_deployment_for_pass_through( # noqa: ANN202 # unannotated before the rename too; its inferred return mixes untyped deployment dicts
+ self,
+ model: str,
+ messages: list[dict[str, str]] | None = None,
+ input: str | list | None = None,
+ specific_deployment: bool | None = False,
+ request_kwargs: dict | None = None,
+ ):
+ """
+ Returns deployments available for pass-through endpoints (based on load balancing strategy)
+
+ Similar to get_available_deployment, but only returns deployments with use_in_pass_through=True
+
+ Args:
+ model: Model name
+ messages: Optional list of messages
+ input: Optional input data
+ specific_deployment: Whether to find a specific deployment
+ request_kwargs: Optional request parameters
+
+ Returns:
+ Dict: Selected deployment configuration
+
+ Raises:
+ BadRequestError: If no deployment is configured with use_in_pass_through=True
+ RouterRateLimitError: If no pass-through deployments are available
+ """
+ # 1. Perform common checks to get healthy deployments list
+ model, healthy_deployments = self._common_checks_available_deployment(
+ model=model,
+ messages=messages,
+ input=input,
+ specific_deployment=specific_deployment,
+ request_kwargs=request_kwargs,
+ )
+
+ strategy, strategy_selector = self._get_routing_context(model, request_kwargs)
+
+ # 2. If the returned is a specific deployment (Dict), verify and return directly
+ if isinstance(healthy_deployments, dict):
+ if (healthy_deployments.get("model_info") or {}).get("blocked") is True:
+ raise litellm_exceptions.ServiceUnavailableError(
+ message=f"Model '{model}' is currently paused and cannot accept requests.",
+ model=model,
+ llm_provider="",
+ )
+ litellm_params: Final = healthy_deployments.get("litellm_params", {})
+ if litellm_params.get("use_in_pass_through"):
+ self._override_selector_pre_call_check(strategy, strategy_selector, healthy_deployments)
+ return healthy_deployments
+ else:
+ # Specific deployment does not support pass-through
+ raise litellm_exceptions.BadRequestError(
+ message=f"Deployment {healthy_deployments.get('model_info', {}).get('id')} does not support pass-through endpoint (use_in_pass_through=False)",
+ model=model,
+ llm_provider="",
+ )
+
+ # 3. Filter deployments that support pass-through
+ pass_through_deployments = self._filter_pass_through_deployments(healthy_deployments=healthy_deployments)
+
+ if len(pass_through_deployments) == 0:
+ # No deployments support pass-through
+ raise litellm_exceptions.BadRequestError(
+ message=f"Model {model} has no deployment configured with use_in_pass_through=True. Please add use_in_pass_through: true in the deployment configuration",
+ model=model,
+ llm_provider="",
+ )
+ pass_through_model_ids: Final = tuple(
+ deployment["model_info"]["id"]
+ for deployment in pass_through_deployments
+ if "id" in deployment.get("model_info", {})
+ )
+
+ # 4. Apply health-check and cooldown filtering
+ parent_otel_span: Final[Span | None] = get_parent_otel_span_from_kwargs(request_kwargs)
+ request_deployments: Final = cast( # cast-ok: router deployments are plain dicts
+ Sequence[DeploymentTypedDict], pass_through_deployments
+ )
+ pass_through_deployments = self._filter_health_check_unhealthy_deployments(
+ healthy_deployments=pass_through_deployments,
+ parent_otel_span=parent_otel_span,
+ )
+ cooldown_deployments: Final = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
+ pass_through_deployments = self._filter_cooldown_deployments(
+ healthy_deployments=pass_through_deployments,
+ cooldown_deployments=cooldown_deployments,
+ )
+ pass_through_deployments = self._filter_blocked_deployments(pass_through_deployments)
+
+ # 5. Apply pre-call checks (if enabled)
+ if self._core_enable_pre_call_checks and (messages is not None or input is not None):
+ pass_through_deployments = self._pre_call_checks(
+ model=model,
+ healthy_deployments=pass_through_deployments,
+ messages=messages,
+ input=input,
+ request_kwargs=request_kwargs,
+ request_deployments=request_deployments,
+ )
+
+ if self._is_priority_routing_group(model):
+ pass_through_deployments = litellm_utils.get_order_filtered_deployments(
+ pass_through_deployments,
+ target_order=request_kwargs.pop("_target_order", None) if request_kwargs is not None else None,
+ )
+
+ if len(pass_through_deployments) == 0:
+ model_ids = self._core_get_model_ids(model_name=model)
+ _cooldown_time = self._core_cooldown_cache.get_min_cooldown(
+ model_ids=model_ids, parent_otel_span=parent_otel_span
+ )
+ _cooldown_list = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
+ raise RouterRateLimitError(
+ model=model,
+ cooldown_time=_cooldown_time,
+ enable_pre_call_checks=self._core_enable_pre_call_checks,
+ cooldown_list=_cooldown_list,
+ model_ids=pass_through_model_ids,
+ cooldown_deployments=get_cooldown_deployment_info(
+ self._core_ctx,
+ self._deployment_ids(
+ self._region_routable_deployments(
+ request_deployments,
+ cast(Mapping[str, object] | None, request_kwargs), # cast-ok: str-keyed request kwargs
+ messages is not None or input is not None,
+ )
+ ),
+ _cooldown_list,
+ ),
+ )
+
+ # 6. Apply load balancing strategy
+ if strategy == "simple-shuffle":
+ return simple_shuffle(
+ resolve_model_alias=self._core_get_model_from_alias,
+ healthy_deployments=pass_through_deployments,
+ model=model,
+ request_kwargs=request_kwargs,
+ )
+ deployment: Final = self._select_deployment_sync(
+ strategy=strategy,
+ selector=strategy_selector,
+ model=model,
+ healthy_deployments=pass_through_deployments,
+ messages=messages,
+ input=input,
+ request_kwargs=request_kwargs,
+ )
+
+ if deployment is None:
+ verbose_router_logger.info(
+ "get_available_deployment_for_pass_through model: %s, no available deployments", model
+ )
+ model_ids = self._core_get_model_ids(model_name=model)
+ _cooldown_time = self._core_cooldown_cache.get_min_cooldown(
+ model_ids=model_ids, parent_otel_span=parent_otel_span
+ )
+ _cooldown_list = get_cooldown_deployments(ctx=self._core_ctx, parent_otel_span=parent_otel_span)
+ raise RouterRateLimitError(
+ model=model,
+ cooldown_time=_cooldown_time,
+ enable_pre_call_checks=self._core_enable_pre_call_checks,
+ cooldown_list=_cooldown_list,
+ model_ids=model_ids,
+ )
+ self._override_selector_pre_call_check(strategy, strategy_selector, deployment)
+
+ verbose_router_logger.info(
+ "get_available_deployment_for_pass_through model: %s, selected deployment: %s",
+ model,
+ self.print_deployment(deployment),
+ )
+ return deployment
+
+ def _filter_cooldown_deployments(
+ self, healthy_deployments: list[dict], cooldown_deployments: list[str]
+ ) -> list[dict]:
+ """
+ Filters out the deployments currently cooling down from the list of healthy deployments
+
+ Args:
+ healthy_deployments: List of healthy deployments
+ cooldown_deployments: List of model_ids cooling down. cooldown_deployments is a list of model_id's cooling down, cooldown_deployments = ["16700539-b3cd-42f4-b426-6a12a1bb706a", "16700539-b3cd-42f4-b426-7899"]
+
+ Returns:
+ List of healthy deployments
+ """
+ if verbose_router_logger.isEnabledFor(logging.DEBUG):
+ verbose_router_logger.debug("cooldown deployments: %s", cooldown_deployments)
+ # Convert to set for O(1) lookup and use list comprehension for O(n) filtering
+ cooldown_set: Final = set(cooldown_deployments)
+ return [deployment for deployment in healthy_deployments if deployment["model_info"]["id"] not in cooldown_set]
+
+ def _filter_blocked_deployments(self, healthy_deployments: list[dict]) -> list[dict]:
+ """
+ Filters out deployments that an admin has paused via `LiteLLM_ProxyModelTable.blocked`.
+
+ Applied alongside the cooldown filter on every routing entry point that calls
+ `_common_checks_available_deployment` directly — the primary sync/async path,
+ the sync pass-through path, and the retry / health-check helpers — so paused
+ deployments never serve a request. The async pass-through path inherits this
+ filter through its delegation to `async_get_healthy_deployments`.
+ """
+ return [
+ deployment
+ for deployment in healthy_deployments
+ if (deployment.get("model_info") or {}).get("blocked") is not True
+ ]
+
+ @staticmethod
+ def _core_is_deployment_blocked(deployment: "Deployment") -> bool:
+ """
+ Returns True when a `Deployment` Pydantic instance carries the admin-paused
+ flag. Used by credential-lookup helpers so passthrough file / batch endpoints
+ cannot bypass the pause by resolving credentials directly.
+ """
+ model_info: Final[object | None] = getattr(deployment, "model_info", None)
+ if model_info is None:
+ return False
+ return getattr(model_info, "blocked", None) is True
+
+ async def _async_filter_health_check_unhealthy_deployments(
+ self,
+ healthy_deployments: list[dict],
+ parent_otel_span: Span | None = None,
+ health_check_probe: bool = False,
+ ) -> list[dict]:
+ """
+ Filter out deployments marked unhealthy by background health checks.
+ No-op when enable_health_check_routing is False.
+ When background_health_check_model_groups is set, only deployments in the
+ listed model groups are filtered; every other group keeps its configured
+ routing strategy untouched, and a router-level allowed_fails_policy no
+ longer disables the filter for the listed groups.
+ Returns all deployments if health state is unavailable, stale, or would
+ exclude every candidate (safety net).
+ """
+ if not self._core_enable_health_check_routing:
+ return healthy_deployments
+
+ # When allowed_fails_policy is set, cooldown is the sole routing exclusion
+ # mechanism -- skip the binary health check filter so the policy threshold
+ # is respected before any deployment is excluded. With a model-group
+ # allowlist the filter is already scoped, so listed groups keep it.
+ scoped_groups: Final = self._core_background_health_check_model_groups
+ if self._core_allowed_fails_policy is not None and scoped_groups is None:
+ return healthy_deployments
+
+ unhealthy_ids: Final = await self._core_health_state_cache.async_get_unhealthy_deployment_ids(
+ parent_otel_span=parent_otel_span
+ )
+ if not unhealthy_ids:
+ return healthy_deployments
+
+ filtered: Final = [
+ d
+ for d in healthy_deployments
+ if d["model_info"]["id"] not in unhealthy_ids
+ or (scoped_groups is not None and d["model_name"] not in scoped_groups)
+ ]
+
+ if not filtered:
+ return [] if health_check_probe else healthy_deployments
+
+ return filtered
+
+ def _filter_health_check_unhealthy_deployments(
+ self,
+ healthy_deployments: list[dict],
+ parent_otel_span: Span | None = None,
+ ) -> list[dict]:
+ """Sync version of _async_filter_health_check_unhealthy_deployments."""
+ if not self._core_enable_health_check_routing:
+ return healthy_deployments
+
+ scoped_groups: Final = self._core_background_health_check_model_groups
+ if self._core_allowed_fails_policy is not None and scoped_groups is None:
+ return healthy_deployments
+
+ unhealthy_ids: Final = self._core_health_state_cache.get_unhealthy_deployment_ids(
+ parent_otel_span=parent_otel_span
+ )
+ if not unhealthy_ids:
+ return healthy_deployments
+
+ filtered: Final = [
+ d
+ for d in healthy_deployments
+ if d["model_info"]["id"] not in unhealthy_ids
+ or (scoped_groups is not None and d["model_name"] not in scoped_groups)
+ ]
+
+ if not filtered:
+ verbose_router_logger.warning("All deployments marked unhealthy by health checks, bypassing health filter")
+ return healthy_deployments
+
+ return filtered
+
+ def _filter_pass_through_deployments(self, healthy_deployments: list[dict]) -> list[dict]:
+ """
+ Filter out deployments configured with use_in_pass_through=True
+
+ Args:
+ healthy_deployments: List of healthy deployments
+
+ Returns:
+ List[Dict]: Only includes a list of deployments that support pass-through
+ """
+ verbose_router_logger.debug(
+ "Filter pass-through deployments from %s healthy deployments", len(healthy_deployments)
+ )
+
+ pass_through_deployments: Final = [
+ deployment
+ for deployment in healthy_deployments
+ if deployment.get("litellm_params", {}).get("use_in_pass_through", False)
+ ]
+
+ verbose_router_logger.debug("Found %s deployments with pass-through enabled", len(pass_through_deployments))
+
+ return pass_through_deployments
+
+ def _track_deployment_metrics(self, deployment, parent_otel_span: Span | None, response=None):
+ """
+ Tracks successful requests rpm usage.
+ """
+ try:
+ model_id: Final = deployment.get("model_info", {}).get("id", None)
+ if response is None:
+ # update self.deployment_stats
+ if model_id is not None:
+ self._update_usage(model_id, parent_otel_span) # update in-memory cache for tracking
+ except Exception as e:
+ verbose_router_logger.error("Error in _track_deployment_metrics: %s", e)
+
+ def get_num_retries_from_retry_policy(self, exception: Exception, model_group: str | None = None):
+ return _get_num_retries_from_retry_policy(
+ exception=exception,
+ model_group=model_group,
+ model_group_retry_policy=self.model_group_retry_policy,
+ retry_policy=self.retry_policy,
+ )
+
+ def _core_get_allowed_fails_from_policy(self, exception: Exception) -> int | None:
+ """
+ BadRequestErrorRetries: Optional[int] = None
+ AuthenticationErrorRetries: Optional[int] = None
+ TimeoutErrorRetries: Optional[int] = None
+ RateLimitErrorRetries: Optional[int] = None
+ ContentPolicyViolationErrorRetries: Optional[int] = None
+ """
+ # if we can find the exception then in the retry policy -> return the number of retries
+ allowed_fails_policy: Final[AllowedFailsPolicy | None] = self._core_allowed_fails_policy
+
+ if allowed_fails_policy is None:
+ return None
+
+ if (
+ isinstance(exception, litellm_exceptions.AuthenticationError)
+ and allowed_fails_policy.AuthenticationErrorAllowedFails is not None
+ ):
+ return allowed_fails_policy.AuthenticationErrorAllowedFails
+ if (
+ isinstance(exception, litellm_exceptions.Timeout)
+ and allowed_fails_policy.TimeoutErrorAllowedFails is not None
+ ):
+ return allowed_fails_policy.TimeoutErrorAllowedFails
+ if (
+ isinstance(exception, litellm_exceptions.RateLimitError)
+ and allowed_fails_policy.RateLimitErrorAllowedFails is not None
+ ):
+ return allowed_fails_policy.RateLimitErrorAllowedFails
+ if (
+ isinstance(exception, litellm_exceptions.ContentPolicyViolationError)
+ and allowed_fails_policy.ContentPolicyViolationErrorAllowedFails is not None
+ ):
+ return allowed_fails_policy.ContentPolicyViolationErrorAllowedFails
+ if (
+ isinstance(exception, litellm_exceptions.BadRequestError)
+ and allowed_fails_policy.BadRequestErrorAllowedFails is not None
+ ):
+ return allowed_fails_policy.BadRequestErrorAllowedFails
+ if (
+ isinstance(exception, litellm_exceptions.InternalServerError)
+ and allowed_fails_policy.InternalServerErrorAllowedFails is not None
+ ):
+ return allowed_fails_policy.InternalServerErrorAllowedFails
+ if (
+ isinstance(exception, litellm_exceptions.ServiceUnavailableError)
+ and allowed_fails_policy.ServiceUnavailableErrorAllowedFails is not None
+ ):
+ return allowed_fails_policy.ServiceUnavailableErrorAllowedFails
+ if (
+ isinstance(exception, litellm_exceptions.BadGatewayError)
+ and allowed_fails_policy.BadGatewayErrorAllowedFails is not None
+ ):
+ return allowed_fails_policy.BadGatewayErrorAllowedFails
+ if (
+ isinstance(exception, litellm_exceptions.NotFoundError)
+ and allowed_fails_policy.NotFoundErrorAllowedFails is not None
+ ):
+ return allowed_fails_policy.NotFoundErrorAllowedFails
+
+ def _initialize_alerting(self):
+ from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
+
+ if self.alerting_config is None:
+ return
+
+ router_alerting_config: Final[AlertingConfig] = self.alerting_config
+
+ _slack_alerting_logger: Final = SlackAlerting(
+ alerting_threshold=router_alerting_config.alerting_threshold,
+ alerting=["slack"],
+ default_webhook_url=router_alerting_config.webhook_url,
+ )
+
+ self._core_slack_alerting_logger = _slack_alerting_logger
+
+ self._host.callbacks.add_litellm_callback(_slack_alerting_logger)
+ self._host.callbacks.add_litellm_success_callback(_slack_alerting_logger.response_taking_too_long_callback)
+ verbose_router_logger.info("\033[94m\nInitialized Alerting for litellm.Router\033[0m\n")
+
+ def _core_set_custom_routing_strategy(self, CustomRoutingStrategy: CustomRoutingStrategyBase) -> None:
+ """
+ Sets get_available_deployment and async_get_available_deployment on an instanced of litellm.Router
+
+ Use this to set your custom routing strategy
+
+ Args:
+ CustomRoutingStrategy: litellm.router.CustomRoutingStrategyBase
+ """
+
+ strategy: Final = CustomRoutingStrategy
+ setattr(
+ self,
+ "get_available_deployment",
+ resolve_pods_after_bound(
+ strategy.get_available_deployment, # pyright: ignore[reportUnknownArgumentType] # legacy stub
+ self._core_kubernetes_pod_discovery,
+ ),
+ )
+ setattr(
+ self,
+ "_core_async_get_available_deployment",
+ async_resolve_pods_after_bound(
+ strategy.async_get_available_deployment, # pyright: ignore[reportUnknownArgumentType] # legacy stub
+ self._core_kubernetes_pod_discovery,
+ ),
+ )
+
+ def _reset_custom_routing_strategy(self) -> None:
+ for attr in ("get_available_deployment", "_core_async_get_available_deployment"):
+ if attr in self.__dict__:
+ delattr(self, attr)
+
+ def flush_cache(self):
+ self._host.set_global_cache(None)
+ self._core_cache.flush_cache()
+ session_in_memory_cache: Final = self._claude_code_session_router_cache.in_memory_cache
+ if session_in_memory_cache is not None:
+ session_in_memory_cache.flush_cache()
+ self._core_prompt_caching_pin_cache.in_memory_cache.flush_cache()
+
+ def reset(self):
+ ## clean up on close
+ self._host.set_callback_list("success_callback", [])
+ self._host.set_callback_list("_async_success_callback", [])
+ self._host.set_callback_list("failure_callback", [])
+ self._host.set_callback_list("_async_failure_callback", [])
+ self.retry_policy = None
+ self.flush_cache()
+
+
+def _name_core_members_after_old_names() -> None:
+ # An old member that moved to ``_core_`` can still be reached as the function itself
+ # (``cached_deployment_model_info`` is an lru_cache over one), so errors that name the method
+ # keep the name it had before.
+ for name, member in vars(PythonCore).items():
+ old_name = name.removeprefix("_core_")
+ if old_name != name and inspect.isfunction(member) and hasattr(DeprecatedRouterMembers, old_name):
+ member.__qualname__ = f"Router.{old_name}"
+
+
+_name_core_members_after_old_names()
diff --git a/litellm/router_python_host.py b/litellm/router_python_host.py
new file mode 100644
index 00000000000..8156740144e
--- /dev/null
+++ b/litellm/router_python_host.py
@@ -0,0 +1,135 @@
+"""
+``PythonHost``: what the router core reaches outside itself, served from the litellm package.
+
+The core never touches ``litellm.*`` directly. Inference calls (``invoke``), backoff sleeps,
+callback registration and the process-wide cache all go through the host, and the process settings
+come from ``dynamic()`` (``litellm/router_config.py``). A Rust core gets the same operations.
+"""
+
+import asyncio
+from collections.abc import Awaitable, Callable, Sequence
+from typing import (
+ TYPE_CHECKING,
+ Final,
+ Literal,
+ TypeAlias,
+ cast, # noqa: TID251 # untyped litellm globals: the live config view and the callback lists
+)
+
+from typing_extensions import overload
+
+import litellm
+from litellm.router_config import RouterDynamicConfig, live_dynamic_config
+
+if TYPE_CHECKING:
+ from pydantic import BaseModel
+
+ from litellm.caching.caching import Cache
+ from litellm.litellm_core_utils.logging_callback_manager import LoggingCallbackManager
+ from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
+ from litellm.text_completions.streaming_iterator import TextCompletionStreamWrapper
+ from litellm.types.llms.openai import HttpxBinaryResponseContent, OpenAIFileObject
+ from litellm.types.rerank import RerankResponse
+ from litellm.types.utils import (
+ AdapterCompletionStreamWrapper,
+ EmbeddingResponse,
+ ImageResponse,
+ LiteLLMBatch,
+ ModelInfo,
+ ModelResponse,
+ TextCompletionResponse,
+ TranscriptionResponse,
+ )
+
+CallbackList: TypeAlias = Literal[
+ "success_callback", "_async_success_callback", "failure_callback", "_async_failure_callback", "input_callback"
+]
+
+Endpoint: TypeAlias = Callable[..., object]
+
+
+class PythonHost:
+ __slots__ = ()
+
+ # The endpoints whose results the router core reads, typed as litellm declares them; any other
+ # op gets the untyped Endpoint.
+ @overload
+ def invoke(self, op: Literal["acompletion"]) -> Callable[..., Awaitable["ModelResponse | CustomStreamWrapper"]]: ...
+ @overload
+ def invoke(
+ self, op: Literal["atext_completion"]
+ ) -> Callable[..., Awaitable["TextCompletionResponse | TextCompletionStreamWrapper"]]: ...
+ @overload
+ def invoke(
+ self, op: Literal["aadapter_completion"]
+ ) -> Callable[..., Awaitable["BaseModel | AdapterCompletionStreamWrapper | None"]]: ...
+ @overload
+ def invoke(self, op: Literal["aembedding"]) -> Callable[..., Awaitable["EmbeddingResponse"]]: ...
+ @overload
+ def invoke(self, op: Literal["aimage_generation"]) -> Callable[..., Awaitable["ImageResponse"]]: ...
+ @overload
+ def invoke(self, op: Literal["atranscription"]) -> Callable[..., Awaitable["TranscriptionResponse"]]: ...
+ @overload
+ def invoke(self, op: Literal["aspeech"]) -> Callable[..., Awaitable["HttpxBinaryResponseContent"]]: ...
+ @overload
+ def invoke(self, op: Literal["arerank"]) -> Callable[..., Awaitable["RerankResponse"]]: ...
+ @overload
+ def invoke(self, op: Literal["acreate_file"]) -> Callable[..., Awaitable["OpenAIFileObject"]]: ...
+ @overload
+ def invoke(self, op: Literal["acreate_batch"]) -> Callable[..., Awaitable["LiteLLMBatch"]]: ...
+ @overload
+ def invoke(self, op: Literal["aretrieve_batch"]) -> Callable[..., Awaitable["LiteLLMBatch"]]: ...
+ @overload
+ def invoke(self, op: Literal["acancel_batch"]) -> Callable[..., Awaitable["LiteLLMBatch"]]: ...
+ @overload
+ def invoke(self, op: Literal["alist_batches"]) -> Callable[..., Awaitable[object]]: ...
+ @overload
+ def invoke(self, op: Literal["get_llm_provider"]) -> Callable[..., "tuple[str, str, str | None, str | None]"]: ...
+ @overload
+ def invoke(self, op: Literal["get_model_info"]) -> Callable[..., "ModelInfo"]: ...
+ @overload
+ def invoke(self, op: Literal["get_supported_openai_params"]) -> Callable[..., "Sequence[str] | None"]: ...
+ @overload
+ def invoke(self, op: Literal["token_counter"]) -> Callable[..., int]: ...
+ @overload
+ def invoke(self, op: Literal["calculate_retry_after"]) -> Callable[..., float]: ...
+ @overload
+ def invoke(self, op: str) -> Endpoint: ...
+ def invoke(self, op: str) -> Endpoint:
+ """The litellm function named ``op`` (``acompletion``, ``get_model_info``, ...), looked up at call time."""
+ endpoint: Final[object] = getattr(litellm, op, None)
+ if endpoint is None:
+ raise AttributeError(f"litellm has no endpoint {op!r}")
+ if not callable(endpoint):
+ raise TypeError(f"litellm.{op} is not an endpoint")
+ return endpoint
+
+ def sleep(self, seconds: float) -> Awaitable[None]:
+ return asyncio.sleep(seconds)
+
+ def dynamic(self) -> RouterDynamicConfig:
+ """The process settings, read live on each access (``current_dynamic_config()`` takes a snapshot)."""
+ return cast(RouterDynamicConfig, live_dynamic_config) # cast-ok: the live view reads the same fields
+
+ @property
+ def callbacks(self) -> "LoggingCallbackManager":
+ return litellm.logging_callback_manager
+
+ def callback_list(self, name: CallbackList) -> list[object]: # mutable-ok: the process callback list itself
+ value: Final = getattr(litellm, name)
+ return cast(list[object], value) # cast-ok: the process callback list is an untyped litellm global
+
+ def set_callback_list(self, name: CallbackList, value: list[object]) -> None: # mutable-ok: assigned as given
+ setattr(litellm, name, value)
+
+ def global_cache(self) -> "Cache | None":
+ return litellm.cache
+
+ def set_global_cache(self, cache: "Cache | None") -> None:
+ litellm.cache = cache
+
+ def suppress_debug_info(self) -> None:
+ litellm.suppress_debug_info = True
+
+
+python_host: Final = PythonHost()
diff --git a/litellm/scheduler.py b/litellm/scheduler.py
index 73196d24e4d..48be61283df 100644
--- a/litellm/scheduler.py
+++ b/litellm/scheduler.py
@@ -1,15 +1,20 @@
import asyncio
import enum
import heapq
+import logging as _logging
import time
from collections.abc import Awaitable, Callable, Sequence
from typing import Final, TypeAlias
+from typing import Protocol as _Protocol
from pydantic import TypeAdapter
from litellm import print_verbose
from litellm._internal_context import with_service_target
+from litellm._logging import verbose_logger as _verbose_logger
from litellm.caching.caching import DualCache, RedisCache
+from litellm.caching.redis_cache import RedisCircuitBreakerOpenError as _RedisCircuitBreakerOpenError
+from litellm.caching.redis_cache import log_redis_failure as _log_redis_failure
from litellm.constants import DEFAULT_IN_MEMORY_TTL, DEFAULT_POLLING_INTERVAL
from litellm.exceptions import Timeout
from litellm.types.llms.base import LiteLLMBaseModel
@@ -30,6 +35,26 @@ class FlowItem(LiteLLMBaseModel):
model_name: str
+class _QueueCache(_Protocol):
+ async def async_get_cache(self, key: str, *, local_only: bool = False, raise_on_error: bool = False) -> object: ...
+
+ async def async_set_cache(self, key: str, value: object, *, local_only: bool = False) -> object: ...
+
+
+async def _read_queue(cache: _QueueCache, redis_cache: _QueueCache | None, key: str) -> object:
+ if redis_cache is None:
+ return await cache.async_get_cache(key=key, local_only=True)
+ from redis.exceptions import RedisError as _RedisError
+
+ try:
+ response: Final = await redis_cache.async_get_cache(key=key, raise_on_error=True)
+ except (_RedisError, _RedisCircuitBreakerOpenError) as error:
+ _log_redis_failure(_verbose_logger, _logging.ERROR, "Scheduler queue using local snapshot", error)
+ return await cache.async_get_cache(key=key, local_only=True)
+ await cache.async_set_cache(key=key, value=[] if response is None else response, local_only=True)
+ return response
+
+
class Scheduler:
cache: DualCache
@@ -153,7 +178,7 @@ class Scheduler:
"""
if self.cache is not None:
_cache_key: Final = f"{SchedulerCacheKeys.queue.value}:{model_name}"
- response: Final = await self.cache.async_get_cache(key=_cache_key)
+ response: Final = await _read_queue(self.cache, self.cache.redis_cache, _cache_key)
if not isinstance(response, list):
return []
return _QUEUE_ENTRIES.validate_python(response)
diff --git a/litellm/types/llms/cohere.py b/litellm/types/llms/cohere.py
index 92d1a8a573d..e48ef73b134 100644
--- a/litellm/types/llms/cohere.py
+++ b/litellm/types/llms/cohere.py
@@ -1,5 +1,6 @@
from typing import Literal
+from pydantic import AliasPath, BaseModel, ConfigDict, Field
from typing_extensions import Required, TypedDict
@@ -98,3 +99,15 @@ class CohereV2ChatResponse(TypedDict):
message: CohereV2ChatResponseMessage
usage: CohereV2ChatResponseUsage
logprobs: CohereV2ChatResponseLogProbs
+
+
+class CohereV2StreamedToolCall(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ id: str | None = Field(default=None, validation_alias=AliasPath("delta", "message", "tool_calls", "id"))
+ name: str | None = Field(
+ default=None, validation_alias=AliasPath("delta", "message", "tool_calls", "function", "name")
+ )
+ arguments: str = Field(
+ default="", validation_alias=AliasPath("delta", "message", "tool_calls", "function", "arguments")
+ )
diff --git a/litellm/types/proxy/management_endpoints/model_management_endpoints.py b/litellm/types/proxy/management_endpoints/model_management_endpoints.py
index f032bb20c2a..b12745679df 100644
--- a/litellm/types/proxy/management_endpoints/model_management_endpoints.py
+++ b/litellm/types/proxy/management_endpoints/model_management_endpoints.py
@@ -35,6 +35,9 @@ class PatchModelRequest(updateDeployment):
class ModelGroupInfoProxy(ModelGroupInfo):
+ supports_background_chat: bool = Field(
+ default=False, description="Whether cache-backed background Responses runs are enabled for this model group"
+ )
is_public_model_group: bool = Field(default=False)
health_status: str | None = Field(default=None)
health_response_time: float | None = Field(default=None)
diff --git a/tests/base_sdk_tests/check_sdk_http.py b/tests/base_sdk_tests/check_sdk_http.py
index f7a1e30ac99..16ee4824f2d 100644
--- a/tests/base_sdk_tests/check_sdk_http.py
+++ b/tests/base_sdk_tests/check_sdk_http.py
@@ -70,7 +70,9 @@ def enqueue(body: object, status: int = 200) -> None:
def enqueue_stream() -> None:
chunk: Final = {**CHAT, "object": "chat.completion.chunk", "choices": [{"index": 0, "delta": {"content": "pong"}}]}
- RESPONSES.put((200, f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n".encode()))
+ terminal: Final = {**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}
+ stream: Final = f"data: {json.dumps(chunk)}\n\ndata: {json.dumps(terminal)}\n\ndata: [DONE]\n\n"
+ RESPONSES.put((200, stream.encode()))
def bedrock_event(event: str, payload: object) -> bytes:
diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py
index 9b61c1d39ae..a84a40c67c1 100644
--- a/tests/code_coverage_tests/recursive_detector.py
+++ b/tests/code_coverage_tests/recursive_detector.py
@@ -3,6 +3,7 @@ import os
IGNORE_FUNCTIONS = [
"_json_cost", # bounded at depth 32 and consumed under byte/node limits.
+ "detached", # router_config: copies only the plain containers of the router's own constructor arguments, once per build or changeset
"_format_type",
"remove_additional_properties",
"remove_strict_from_schema",
diff --git a/tests/code_coverage_tests/router_litellm_globals_check.py b/tests/code_coverage_tests/router_litellm_globals_check.py
new file mode 100644
index 00000000000..ff9fac0e3dc
--- /dev/null
+++ b/tests/code_coverage_tests/router_litellm_globals_check.py
@@ -0,0 +1,54 @@
+"""
+The router and its cores never read or write ``litellm.*``.
+
+Process settings reach the router through ``RouterDynamicConfig`` (litellm/router_config.py) and
+everything else outside it (inference calls, callbacks, the global cache) through ``PythonHost``
+(litellm/router_python_host.py). Those two modules are the only router code allowed to touch the
+``litellm`` package namespace. This check fails on ``import litellm`` (or ``import litellm.``,
+which binds the same name) and on any ``litellm.`` access in the files below.
+
+Run: python tests/code_coverage_tests/router_litellm_globals_check.py
+"""
+
+import ast
+import os
+import sys
+
+ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+CHECKED = (
+ "litellm/router.py",
+ "litellm/router_api.py",
+ "litellm/router_core.py",
+ "litellm/router_deprecated.py",
+ "litellm/router_python_core.py",
+)
+
+
+def violations(rel: str) -> list[str]:
+ tree = ast.parse(open(os.path.join(ROOT, rel)).read(), rel)
+ found = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ found.extend(
+ f"{rel}:{node.lineno}: import {alias.name}"
+ for alias in node.names
+ if alias.asname is None and (alias.name == "litellm" or alias.name.startswith("litellm."))
+ )
+ elif isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id == "litellm":
+ found.append(f"{rel}:{node.lineno}: litellm.{node.attr}")
+ return found
+
+
+def main() -> int:
+ found = [line for rel in CHECKED for line in violations(rel)]
+ for line in found:
+ print(line)
+ if found:
+ print(f"{len(found)} litellm.* uses in router modules; read settings from RouterDynamicConfig and use PythonHost")
+ return 1
+ print("router modules use no litellm.* globals")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/code_coverage_tests/router_surface_check.py b/tests/code_coverage_tests/router_surface_check.py
index 729ba8ab7a4..a8e0d6fa0d7 100644
--- a/tests/code_coverage_tests/router_surface_check.py
+++ b/tests/code_coverage_tests/router_surface_check.py
@@ -38,11 +38,15 @@ ALLOWLIST = os.path.join(os.path.dirname(__file__), "router_surface_allowlist.js
ROUTER_FILES = {
"litellm/router.py",
"litellm/router_api.py",
+ "litellm/router_config.py",
+ "litellm/router_core.py",
+ "litellm/router_python_core.py",
+ "litellm/router_python_host.py",
"litellm/router_deprecated.py",
"litellm/router_host.py",
}
-ROUTER_CLASS_FILES = ["litellm/router.py", "litellm/router_deprecated.py"]
-ROUTER_CLASS_NAMES = {"Router", "DeprecatedRouterMembers"}
+ROUTER_CLASS_FILES = ["litellm/router.py", "litellm/router_python_core.py", "litellm/router_deprecated.py"]
+ROUTER_CLASS_NAMES = {"Router", "PythonCore", "DeprecatedRouterMembers"}
SCAN_ROOTS = ["litellm", "enterprise"]
SKIP_DIRS = {
diff --git a/tests/integration/_support/anthropic_refusal.py b/tests/integration/_support/anthropic_refusal.py
index 6d5fd236074..fb6c5e71a82 100644
--- a/tests/integration/_support/anthropic_refusal.py
+++ b/tests/integration/_support/anthropic_refusal.py
@@ -29,6 +29,7 @@ DOCS_STREAMING_REFUSAL_URL: Final = (
)
# Copied verbatim from DOCS_REFUSAL_URL and DOCS_STREAMING_REFUSAL_URL on 2026-10-12.
+DOCS_REFUSAL_EXPLANATION: Final = "This request was declined because it could enable cyber harm."
DOCS_REFUSAL: Final[Event] = {
"id": "msg_01XFUDYJgAACzvnptvVoYEL",
"type": "message",
@@ -39,7 +40,7 @@ DOCS_REFUSAL: Final[Event] = {
"stop_details": {
"type": "refusal",
"category": "cyber",
- "explanation": "This request was declined because it could enable cyber harm.",
+ "explanation": DOCS_REFUSAL_EXPLANATION,
},
"usage": {"input_tokens": 412, "output_tokens": 0},
}
@@ -50,7 +51,7 @@ DOCS_PARTIAL_REFUSAL: Final[Event] = {
"stop_details": {
"type": "refusal",
"category": "cyber",
- "explanation": "This request was declined because it could enable cyber harm.",
+ "explanation": DOCS_REFUSAL_EXPLANATION,
},
}
diff --git a/tests/integration/authorization/test_agent_access.py b/tests/integration/authorization/test_agent_access.py
index 09e2afd289d..8c4d5714c50 100644
--- a/tests/integration/authorization/test_agent_access.py
+++ b/tests/integration/authorization/test_agent_access.py
@@ -1,7 +1,8 @@
+import itertools
import json
import uuid
from collections.abc import Callable
-from typing import Final
+from typing import Final, Literal
import pytest
from integration._support.client import Gateway, Scenario
@@ -18,6 +19,22 @@ class _JsonRpcResult(BaseModel):
result: dict[str, JsonValue]
+class _JsonRpcErrorBody(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ code: int
+ message: str
+ data: dict[str, JsonValue]
+
+
+class _JsonRpcError(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ jsonrpc: Literal["2.0"]
+ id: str | int | None
+ error: _JsonRpcErrorBody
+
+
_STREAM_EVENTS: Final = (
{"kind": "status-update", "taskId": "t1", "contextId": "c1", "status": {"state": "working"}, "final": False},
{
@@ -163,6 +180,14 @@ def test_agent_permissions_on_keys_teams_and_access_groups_gate_a2a_send_and_car
separators=(",", ":"),
)
+ def jsonrpc_refusal(text: str, identity: str) -> bool:
+ error: Final = _JsonRpcError.model_validate_json(text)
+ return (error.id, error.error.code, error.error.message) == (
+ marker,
+ -32000,
+ f"Agent '{identity}' is not allowed for your key/team. Contact proxy admin for access.",
+ )
+
spellings: Final = (
("send by id", lambda agent, name: f"/a2a/{agent}", send_payload, "message/send"),
("send by name", lambda agent, name: f"/a2a/{name}", send_payload, "message/send"),
@@ -224,13 +249,13 @@ def test_agent_permissions_on_keys_teams_and_access_groups_gate_a2a_send_and_car
for label, key in callers.items():
for spelling, status, text in attempt(key, denied, denied_name):
- refused: Final = refusal(denied_name if "by name" in spelling else denied)
- assert (status, text) == (403, refused), f"{label} {spelling}: {status} {text}"
+ identity: Final = denied_name if "by name" in spelling else denied
+ assert status == 403 and jsonrpc_refusal(text, identity), f"{label} {spelling}: {status} {text}"
for spelling, payload in denied_tasks:
response: Final = gateway.client.post(
f"/a2a/{denied}", headers={"Authorization": f"Bearer {key}"}, json=payload
)
- assert (response.status_code, response.text) == (403, refusal(denied)), (
+ assert response.status_code == 403 and jsonrpc_refusal(response.text, denied), (
f"{label} {spelling}: {response.status_code} {response.text}"
)
assert card(key, denied) == (403, refusal(denied)), label
@@ -278,13 +303,72 @@ def test_agent_permissions_on_keys_teams_and_access_groups_gate_a2a_send_and_car
emptied: Final = gateway.request("PUT", f"/v1/access_group/{group_id}", {"access_agent_ids": []})
assert emptied.status_code == 200, emptied.text
emptied_outcomes: Final = attempt(callers["access group"], allowed, allowed_name)
- assert emptied_outcomes == tuple(
- (spelling, 403, refusal(allowed_name if "by name" in spelling else allowed))
- for spelling, _, _ in emptied_outcomes
+ assert all(
+ status == 403 and jsonrpc_refusal(text, allowed_name if "by name" in spelling else allowed)
+ for spelling, status, text in emptied_outcomes
), emptied_outcomes
assert wire.drain() == (), emptied_outcomes
+def test_a2a_refusals_answer_as_jsonrpc_errors_that_echo_the_callers_request_id(gateway: Gateway) -> None:
+ marker: Final = "a2arefuse" + uuid.uuid4().hex[:12]
+
+ def upstream(request: Request) -> Reply:
+ return Reply(body=json.dumps(_peer_card(wire.url, marker)).encode())
+
+ with wire_server(upstream) as wire, gateway.scenario() as scenario:
+ agent: Final = _register_agent(gateway, scenario, marker, wire.url)
+ refused_callers: Final = (
+ (
+ "over budget",
+ scenario.key(object_permission={"agents": [agent]}, max_budget=0.0001, spend=1.0),
+ 422,
+ "Budget has been exceeded!",
+ "budget_exceeded",
+ ),
+ (
+ "agent not granted",
+ scenario.key(object_permission={"agents": [marker + "-other"]}),
+ 403,
+ f"Agent '{agent}' is not allowed for your key/team. Contact proxy admin for access.",
+ None,
+ ),
+ ("unknown key", "sk-" + uuid.uuid4().hex, 401, "Authentication Error", None),
+ )
+ message: Final = {
+ "kind": "message",
+ "role": "user",
+ "messageId": marker + "-in",
+ "parts": [{"kind": "text", "text": "ping"}],
+ }
+ requests: Final = tuple(
+ (path, method, request_id)
+ for path, (method, request_id) in itertools.product(
+ (f"/a2a/{agent}", f"/a2a/{agent}/message/send", f"/v1/a2a/{agent}/message/send"),
+ (("message/send", marker), ("message/stream", 7), ("SendMessage", "l10")),
+ )
+ )
+ for label, key, status, message_start, error_type in refused_callers:
+ for path, method, request_id in requests:
+ response = gateway.client.post(
+ path,
+ headers={"Authorization": f"Bearer {key}", "Accept": "application/json, text/event-stream"},
+ json={"jsonrpc": "2.0", "id": request_id, "method": method, "params": {"message": message}},
+ )
+ context = f"{label} {path} {method}: {response.status_code} {response.text}"
+ assert response.status_code == status, context
+ error = _JsonRpcError.model_validate_json(response.text)
+ assert (error.id, error.error.code) == (request_id, -32000), context
+ assert error.error.message.startswith(message_start), context
+ assert error_type is None or error.error.data["type"] == error_type, context
+ unparseable: Final = gateway.client.post(
+ f"/a2a/{agent}", headers={"Authorization": "Bearer sk-" + uuid.uuid4().hex}, content=b"not json"
+ )
+ assert unparseable.status_code == 400, unparseable.text
+ assert _JsonRpcError.model_validate_json(unparseable.text).id is None, unparseable.text
+ assert wire.drain() == ()
+
+
def test_legacy_agent_json_card_path_applies_agent_permissions_to_non_admin_keys(gateway: Gateway) -> None:
pytest.skip(
"BUG: GET /a2a/{agent_id}/.well-known/agent.json returns 401 'Only proxy admin can be used' for non-admin keys because agent_inference_routes lists only agent-card.json"
diff --git a/tests/integration/authorization/test_agent_least_privilege.py b/tests/integration/authorization/test_agent_least_privilege.py
index d993920a32e..a08c4047b9e 100644
--- a/tests/integration/authorization/test_agent_least_privilege.py
+++ b/tests/integration/authorization/test_agent_least_privilege.py
@@ -221,8 +221,13 @@ def test_agent_access_default_deny_denies_ungranted_key_but_keeps_explicit_grant
denied_sends: Final = tuple(
_send(candidate, agent_id, key_a, f"denied-{index}-{uuid.uuid4().hex}") for index in range(8)
)
- _assert_denial(denied_sends[0], agent_id)
- assert all(response.json() == denied_sends[0].json() for response in denied_sends), [
+ assert denied_sends[0].status_code == 403, denied_sends[0].text
+ assert denied_sends[0].json()["error"] == {
+ "code": -32000,
+ "message": f"Agent '{agent_id}' is not allowed for your key/team. Contact proxy admin for access.",
+ "data": denied_sends[0].json()["error"]["data"],
+ }, denied_sends[0].text
+ assert all(response.json()["error"] == denied_sends[0].json()["error"] for response in denied_sends), [
response.text for response in denied_sends
]
denied_completion: Final = _chat_completion(candidate, agent_name, key_a)
diff --git a/tests/integration/compatibility/test_a2a_wire_versions.py b/tests/integration/compatibility/test_a2a_wire_versions.py
index 621dcfe060e..981700080e4 100644
--- a/tests/integration/compatibility/test_a2a_wire_versions.py
+++ b/tests/integration/compatibility/test_a2a_wire_versions.py
@@ -348,6 +348,105 @@ _TRANSPORT_HEADERS: Final = frozenset(
)
+def _a2a_auth_requests(
+ gateway: Gateway, method: str, agent_options: dict[str, JsonValue], client_headers: dict[str, str]
+) -> tuple[Request, ...]:
+ marker: Final = "a2akey" + uuid.uuid4().hex[:12]
+
+ def upstream(request: Request) -> Reply:
+ if request.method == "GET":
+ return Reply(body=json.dumps(_peer_card(wire.url, marker)).encode())
+ body: Final = json.loads(request.body)
+ if body["method"] == "message/stream":
+ frames: Final = tuple(
+ f"data: {json.dumps({'jsonrpc': '2.0', 'id': body['id'], 'result': event})}\n\n".encode()
+ for event in _STREAM_EVENTS
+ )
+ return Reply(content_type="text/event-stream", chunks=frames)
+ if body["method"] == "tasks/get":
+ return Reply(
+ body=json.dumps(
+ {
+ "jsonrpc": "2.0",
+ "id": body["id"],
+ "result": {"kind": "task", "id": "t1", "contextId": "c1", "status": {"state": "completed"}},
+ }
+ ).encode()
+ )
+ return _message_reply(body, "key test")
+
+ with wire_server(upstream) as wire, gateway.scenario() as scenario:
+ identity: Final = _register_agent(
+ gateway,
+ scenario,
+ {"agent_name": marker, "agent_card_params": _peer_card(wire.url, marker, "1.0"), **agent_options},
+ )
+ params: Final = {"id": "t1"} if method == "tasks/get" else {"message": _MESSAGE_V10}
+ headers: Final = {
+ "Authorization": f"Bearer {gateway.key}",
+ "a2a-version": "1.0",
+ **{name.replace("{agent}", marker): value for name, value in client_headers.items()},
+ }
+ response: Final = gateway.client.post(
+ f"/a2a/{identity}/message/send",
+ headers=headers,
+ json={"jsonrpc": "2.0", "id": marker, "method": method, "params": params},
+ )
+ assert response.status_code == 200, response.text
+ assert "error" not in response.text, response.text
+ return wire.drain()
+
+
+@pytest.mark.parametrize(
+ ("method", "observed_method"),
+ (("SendMessage", "GET"), ("SendMessage", "POST"), ("SendStreamingMessage", "POST"), ("tasks/get", "POST")),
+ ids=("card", "send", "stream", "task"),
+)
+def test_regression_db_agent_api_key_is_bearer_on_each_a2a_hop(
+ gateway: Gateway, method: str, observed_method: str
+) -> None:
+ key: Final = "synthetic-backend-" + uuid.uuid4().hex
+ requests: Final = _a2a_auth_requests(gateway, method, {"litellm_params": {"api_key": key}}, {})
+ observed: Final = tuple(request for request in requests if request.method == observed_method)
+ assert len(observed) == 1, requests
+ assert observed[0].headers.get("authorization") == f"Bearer {key}", observed[0].headers
+
+
+@pytest.mark.parametrize("header_name", ("Authorization", "aUtHoRiZaTiOn"))
+def test_guard_static_authorization_overrides_api_key(gateway: Gateway, header_name: str) -> None:
+ requests: Final = _a2a_auth_requests(
+ gateway,
+ "SendMessage",
+ {"litellm_params": {"api_key": "backend-key"}, "static_headers": {header_name: "Bearer static-key"}},
+ {},
+ )
+ assert tuple(request.method for request in requests) == ("GET", "POST"), requests
+ assert tuple(request.headers.get("authorization") for request in requests) == ("Bearer static-key",) * 2
+
+
+@pytest.mark.parametrize("forwarding", ("configured", "convention"))
+def test_guard_forwarded_authorization_arrives_with_api_key(gateway: Gateway, forwarding: str) -> None:
+ options: Final = {
+ "litellm_params": {"api_key": "backend-key"},
+ **({"extra_headers": ["authorization"]} if forwarding == "configured" else {}),
+ }
+ client_headers: Final = (
+ {"x-litellm-api-key": gateway.key, "Authorization": "Bearer client-agent-key"}
+ if forwarding == "configured"
+ else {"x-a2a-{agent}-authorization": "Bearer client-agent-key"}
+ )
+ requests: Final = _a2a_auth_requests(gateway, "SendMessage", options, client_headers)
+ assert tuple(request.method for request in requests) == ("GET", "POST"), requests
+ assert tuple(request.headers.get("authorization") for request in requests) == ("Bearer client-agent-key",) * 2
+
+
+def test_guard_no_agent_key_or_proxy_caller_key_is_sent(gateway: Gateway) -> None:
+ requests: Final = _a2a_auth_requests(gateway, "SendMessage", {}, {})
+ assert tuple(request.method for request in requests) == ("GET", "POST"), requests
+ assert tuple(request.headers.get("authorization") for request in requests) == (None, None)
+ assert all(gateway.key not in value for request in requests for value in request.headers.values()), requests
+
+
@pytest.mark.parametrize(
("version", "method", "message"),
(("0.3", "message/stream", _MESSAGE_V03), ("1.0", "SendStreamingMessage", _MESSAGE_V10)),
diff --git a/tests/integration/observability/test_otel_team_capture_message_content.py b/tests/integration/observability/test_otel_team_capture_message_content.py
index 311bc791870..a206a1828e9 100644
--- a/tests/integration/observability/test_otel_team_capture_message_content.py
+++ b/tests/integration/observability/test_otel_team_capture_message_content.py
@@ -303,6 +303,23 @@ def _with_content(url: str, since: int, sent: Sent) -> tuple[Span, ...]:
)
+def _is_root(span: Span) -> bool:
+ return span["kind"] == SERVER and span["name"].startswith("POST")
+
+
+def _with_root(url: str, since: int, sent: Sent) -> tuple[Span, ...]:
+ """The copy of ``sent`` at ``url`` once its model span, the marker and its server span arrived."""
+ return eventually(
+ lambda: _of_request(url, since, sent),
+ lambda spans: (
+ bool(_model_spans(spans))
+ and any(_carries(span, sent.marker) for span in spans)
+ and any(_is_root(span) for span in spans)
+ ),
+ seconds=40,
+ )
+
+
def _arrived(
url: str, since: int, sent: Sent, expected: frozenset[str], credential: Credential | None = None
) -> tuple[Span, ...]:
@@ -626,11 +643,7 @@ def test_stalled_tenant_during_a_mixed_burst_keeps_serving_and_each_teams_policy
configure_sink(rig.sinks.tenant, paused=False)
assert [result.status for result in sent] == [200] * 30, [result.text for result in sent if result.status != 200]
for (key, _, _), result in zip(plan, sent, strict=True):
- roots = [
- span
- for span in _with_content(rig.sinks.operator, cursors.operator, result)
- if span["kind"] == SERVER and span["name"].startswith("POST ")
- ]
+ roots = [span for span in _with_root(rig.sinks.operator, cursors.operator, result) if _is_root(span)]
assert len(roots) == 1, roots
if key == restricted:
_assert_redacted_twin(rig, result, cursors, rig.sinks.tenant, cursors.tenant)
diff --git a/tests/integration/providers/test_anthropic_refusal_stop_details_wire.py b/tests/integration/providers/test_anthropic_refusal_stop_details_wire.py
index cd42e816ee3..c9a85545d8b 100644
--- a/tests/integration/providers/test_anthropic_refusal_stop_details_wire.py
+++ b/tests/integration/providers/test_anthropic_refusal_stop_details_wire.py
@@ -18,6 +18,7 @@ from integration._support.anthropic_refusal import (
DECLARED_PARTIAL_JSON,
DOCS_PARTIAL_TEXT,
DOCS_REFUSAL,
+ DOCS_REFUSAL_EXPLANATION,
DOCS_STOP_DETAILS,
ENVELOPED_PARTIAL_JSON,
FIXTURES,
@@ -651,8 +652,7 @@ def test_family_messages_stream_refusal_keeps_frames_and_logs_the_stop_details(r
model: Final = _deployment(scenario, provider, rig)
key: Final = scenario.key()
events: Final = _messages_stream(rig, key, model, refusal_prompt("refusal", _marker()))
- native: Final = {"stop_reason": "refusal"} if provider == "snowflake" else REFUSAL_FIELDS
- assert _native_deltas(events) == (native,), events
+ assert _native_deltas(events) == (REFUSAL_FIELDS,), events
assert _relayed_in(events) == (), events
assert _paths(rig.wire) == (_STREAM_TARGETS[provider][1],)
logged: Final = _logged_response(rig, key)
@@ -783,10 +783,10 @@ def test_responses_cache_hit_logs_the_same_stop_details_as_the_miss(rig: Rig) ->
served_from_cache: Final = tuple(event.get("cache_hit") is True for event in logged)
assert (len(_paths(rig.wire)), served_from_cache.count(True)) == (1, 1), (served_from_cache, logged)
assert relayed(first) == ((_RESPONSE_FIELDS, REFUSAL_FIELDS),), first
- assert tuple(relayed(event["response"]) for event in logged) == (((_CHAT_FIELDS, REFUSAL_FIELDS),),) * 2, (
- served_from_cache,
- logged,
- )
+ assert tuple(relayed(event["response"]) for event in logged) == (
+ ((_CHAT_FIELDS, REFUSAL_FIELDS),),
+ relayed(first),
+ ), (served_from_cache, logged)
def test_passthrough_is_not_served_from_the_response_cache(rig: Rig) -> None:
@@ -1310,6 +1310,7 @@ def test_chat_refusal_body_serializes_the_stop_details_beside_the_existing_messa
"message": {
"content": content,
"role": "assistant",
+ "refusal": DOCS_REFUSAL_EXPLANATION,
"provider_specific_fields": {**_ANTHROPIC_MESSAGE_FIELDS, **REFUSAL_FIELDS},
},
}
diff --git a/tests/integration/providers/test_cache_control_tool_call_marks_wire.py b/tests/integration/providers/test_cache_control_tool_call_marks_wire.py
index a18224a9f36..9fc13c77bcc 100644
--- a/tests/integration/providers/test_cache_control_tool_call_marks_wire.py
+++ b/tests/integration/providers/test_cache_control_tool_call_marks_wire.py
@@ -354,7 +354,7 @@ _WEB_RESULTS: Final[dict[str, JsonValue]] = {
),
pytest.param(
{},
- [ASK_LABEL, tool_use_label(CITIES[0]), tool_use_label(CITIES[1]), "assistant:tool_use:srvtoolu_web"],
+ [SYSTEM_LABEL, ASK_LABEL, tool_use_label(CITIES[0]), tool_use_label(CITIES[1])],
id="server-tool-without-result",
),
),
@@ -413,16 +413,14 @@ _GEMINI_CACHE_WRITE: Final = [
@pytest.mark.parametrize(
- ("calls", "expected"),
+ "calls",
(
- pytest.param(
- marked_calls(), [("POST", "/models/gemini-3.8-flash:generateContent")], id="tool-calls-fill-the-cap"
- ),
- pytest.param([tool_call(city) for city in CITIES], _GEMINI_CACHE_WRITE, id="unmarked-tool-calls"),
+ pytest.param(marked_calls(), id="tool-calls-fill-the-cap"),
+ pytest.param([tool_call(city) for city in CITIES], id="unmarked-tool-calls"),
),
)
-def test_gemini_context_cache_follows_the_cap_census(
- gateway: Gateway, calls: list[JsonValue], expected: list[tuple[str, str]]
+def test_gemini_context_cache_takes_the_system_message_with_the_marked_ask(
+ gateway: Gateway, calls: list[JsonValue]
) -> None:
marker: Final = new_marker()
long_system: Final[JsonValue] = {"role": "system", "content": "lorem " * (2 * get_prompt_cache_min_tokens(_GEMINI))}
@@ -436,8 +434,18 @@ def test_gemini_context_cache_follows_the_cap_census(
)
status, _, text = post_chat(gateway, chat_body(model, messages))
assert status == 200, text
- targets: Final = [(request.method, request.target.split("?")[0]) for request in wire.drain()]
- assert targets == expected
+ received: Final = wire.drain()
+ targets: Final = [(request.method, request.target.split("?")[0]) for request in received]
+ cache_writes: Final = [
+ request.body for request in received if request.method == "POST" and "cachedContents" in request.target
+ ]
+ generate: Final = _JSON_OBJECT.validate_json(received[-1].body)
+ assert (
+ targets,
+ [b"lorem lorem" in body for body in cache_writes],
+ generate.get("cachedContent"),
+ "system_instruction" in generate,
+ ) == (_GEMINI_CACHE_WRITE, [True], "cachedContents/census", False), (cache_writes, generate)
def _openai_marks(request: Request) -> list[str]:
@@ -747,11 +755,11 @@ def test_request_level_points_skip_injection_when_client_marks_fill_the_cap(gate
_OTHER_CALL_RESULT: Final[dict[str, JsonValue]] = {
"web_search_results": [{"type": "web_search_tool_result", "tool_use_id": "srvtoolu_other", "content": []}]
}
-_SERVER_MARK_COUNTED: Final = [
+_UNRUN_SERVER_MARK_UNCOUNTED: Final = [
+ SYSTEM_LABEL,
ASK_LABEL,
tool_use_label(CITIES[0]),
tool_use_label(CITIES[1]),
- "assistant:tool_use:srvtoolu_web",
]
@@ -767,7 +775,7 @@ _SERVER_MARK_COUNTED: Final = [
pytest.param(_OTHER_CALL_RESULT, id="result-for-another-call"),
),
)
-def test_malformed_provider_specific_fields_count_the_server_tool_call_mark(
+def test_malformed_provider_specific_fields_leave_the_unrun_server_tool_call_mark_uncounted(
gateway: Gateway, fields: JsonValue
) -> None:
marker: Final = new_marker()
@@ -779,7 +787,7 @@ def test_malformed_provider_specific_fields_count_the_server_tool_call_mark(
)
assert status == 200, text
labels: Final = anthropic_labels(_only_request(wire))
- assert labels == _SERVER_MARK_COUNTED
+ assert labels == _UNRUN_SERVER_MARK_UNCOUNTED
@pytest.mark.parametrize(
diff --git a/tests/integration/providers/test_watsonx_passthrough_router_credentials_wire.py b/tests/integration/providers/test_watsonx_passthrough_router_credentials_wire.py
new file mode 100644
index 00000000000..f20e6d35cf9
--- /dev/null
+++ b/tests/integration/providers/test_watsonx_passthrough_router_credentials_wire.py
@@ -0,0 +1,81 @@
+import json
+import uuid
+from pathlib import Path
+from typing import Final
+
+import yaml
+from integration._support.client import Gateway, JsonValue, object_value
+from integration._support.process import owned_proxy
+from integration._support.wire import Reply, Request, wire_server
+
+_DEPLOYMENT_KEY: Final = "synthetic-watsonx-deployment-key"
+_IAM_TOKEN: Final = "synthetic-watsonx-iam-token"
+_WATSONX_ENV: Final = (
+ "WATSONX_API_KEY",
+ "WATSONX_APIKEY",
+ "WX_API_KEY",
+ "WATSONX_API_BASE",
+ "WATSONX_URL",
+ "WX_URL",
+ "WML_URL",
+ "WATSONX_TOKEN",
+ "WATSONX_ZENAPIKEY",
+)
+
+
+def _config(directory: Path, api_base: str) -> Path:
+ stock: Final = object_value(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
+ config: Final[dict[str, JsonValue]] = {
+ **stock,
+ "model_list": [
+ {
+ "model_name": "granite",
+ "litellm_params": {
+ "model": "watsonx/ibm/granite-3-8b-instruct",
+ "api_key": _DEPLOYMENT_KEY,
+ "api_base": api_base,
+ "project_id": "synthetic-project",
+ "use_in_pass_through": True,
+ },
+ }
+ ],
+ }
+ path: Final = directory / "watsonx-passthrough.yaml"
+ path.write_text(yaml.safe_dump(config))
+ return path
+
+
+def test_watsonx_passthrough_uses_the_flagged_deployment_key_and_base(gateway: Gateway, tmp_path: Path) -> None:
+ marker: Final = "watsonx-" + uuid.uuid4().hex
+
+ def respond(request: Request) -> Reply:
+ if request.method == "POST" and request.target == "/identity/token":
+ assert f"apikey={_DEPLOYMENT_KEY}".encode() in request.body, request.body
+ return Reply(body=json.dumps({"access_token": _IAM_TOKEN, "expires_in": 3600}).encode())
+ if request.method == "POST" and request.target.startswith("/ml/v1/text/generation?"):
+ assert request.headers["authorization"] == f"Bearer {_IAM_TOKEN}", request.headers
+ assert marker.encode() in request.body, request.body[:400]
+ return Reply(
+ body=json.dumps(
+ {"model_id": "ibm/granite-3-8b-instruct", "results": [{"generated_text": marker}]}
+ ).encode()
+ )
+ return Reply(status=404)
+
+ with wire_server(respond) as wire:
+ with owned_proxy(
+ gateway,
+ tmp_path,
+ {"WATSONX_IAM_URL": f"{wire.url}/identity/token"},
+ config=_config(tmp_path, wire.url),
+ remove_environment=_WATSONX_ENV,
+ ) as candidate:
+ response: Final = candidate.request(
+ "POST",
+ "/watsonx/ml/v1/text/generation",
+ {"model_id": "ibm/granite-3-8b-instruct", "input": marker, "project_id": "synthetic-project"},
+ )
+ assert response.status_code == 200, response.text
+ assert response.json()["results"][0]["generated_text"] == marker
+ forwarded: Final = tuple((request.method, request.target.split("?")[0]) for request in wire.drain())
+ assert forwarded == (("POST", "/identity/token"), ("POST", "/ml/v1/text/generation")), forwarded
diff --git a/tests/integration/streaming/test_decision_model_buffering.py b/tests/integration/streaming/test_decision_model_buffering.py
index a2594fd2b9b..d6fea067d69 100644
--- a/tests/integration/streaming/test_decision_model_buffering.py
+++ b/tests/integration/streaming/test_decision_model_buffering.py
@@ -229,11 +229,17 @@ def _frames(identity: str, endpoint: str) -> tuple[str, ...]:
'object': 'chat.completion.chunk',
'created': 1,
'model': 'scripted-chat',
- 'choices': [{'index': 0, 'delta': {'content': text}, 'finish_reason': None}],
+ 'choices': [
+ {
+ 'index': 0,
+ 'delta': {'content': text},
+ 'finish_reason': 'stop' if index == len(pieces) - 1 else None,
+ }
+ ],
}
)
}"
- for text in pieces
+ for index, text in enumerate(pieces)
),
"data: [DONE]",
)
diff --git a/tests/integration/streaming/test_stream_clean_eof_without_finish_reason.py b/tests/integration/streaming/test_stream_clean_eof_without_finish_reason.py
new file mode 100644
index 00000000000..f3b427d91c4
--- /dev/null
+++ b/tests/integration/streaming/test_stream_clean_eof_without_finish_reason.py
@@ -0,0 +1,564 @@
+import asyncio
+import itertools
+import json
+import uuid
+from collections import Counter
+from collections.abc import Callable, Iterator, Mapping
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import ExitStack
+from pathlib import Path
+from typing import Final, Literal
+
+import anthropic
+import httpx
+import openai
+import pytest
+from integration._support.anthropic_sse import CONTENT_BLOCK_START, CONTENT_TAIL, message_start, text_delta
+from integration._support.client import Gateway, Scenario, eventually, string_value
+from integration._support.database import read_rows, scratch_database
+from integration._support.database_relay import database_relay, plaintext_database_url
+from integration._support.openai_wire import answering_model_discovery, posted_targets
+from integration._support.process import owned_proxy, owned_proxy_process
+from integration._support.responses_stream import data_frames, spend_row, sse_events
+from integration._support.wire import Reply, Request, Wire, wire_server
+from integration.providers._count_tokens_system_lift import (
+ anthropic_client,
+ async_anthropic_client,
+ async_openai_client,
+ openai_client,
+)
+from pydantic import JsonValue
+
+Ending = Literal["ending-complete", "ending-clean-eof", "ending-done-without-finish", "ending-done-only"]
+Family = Literal["openai", "anthropic", "hosted_vllm", "gemini", "langgraph", "agentcore"]
+
+_ENDINGS: Final[tuple[Ending, ...]] = (
+ "ending-complete",
+ "ending-clean-eof",
+ "ending-done-without-finish",
+ "ending-done-only",
+)
+_PARTIAL: Final = ('{"findings":[{"title":', '"unfinished')
+_BACKEND: Final = "gpt-5.6"
+_CLAUDE: Final = "claude-sonnet-5-5"
+_GEMINI: Final = "gemini-3.8-flash"
+_AGENT_RUNTIME: Final = "arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/integration-agent"
+_PROVIDER_KEY: Final = "synthetic-provider-key"
+_CALL_ID_HEADER: Final = "x-litellm-call-id"
+_USAGE: Final = {"prompt_tokens": 9, "completion_tokens": 7, "total_tokens": 16}
+_TARGETS: Final[Mapping[Family, str]] = {
+ "openai": "/chat/completions",
+ "hosted_vllm": "/chat/completions",
+ "anthropic": "/v1/messages",
+ "gemini": f"/models/{_GEMINI}:streamGenerateContent",
+ "langgraph": "/runs/stream",
+ "agentcore": "/runtimes/",
+}
+_DEPLOYMENTS: Final[Mapping[Family, str]] = {
+ "openai": f"openai/{_BACKEND}",
+ "hosted_vllm": f"hosted_vllm/{_BACKEND}",
+ "anthropic": f"anthropic/{_CLAUDE}",
+ "gemini": f"gemini/{_GEMINI}",
+ "langgraph": "langgraph/agent",
+ "agentcore": f"bedrock/agentcore/{_AGENT_RUNTIME}",
+}
+
+
+def _data(frame: JsonValue) -> bytes:
+ return b"data: " + json.dumps(frame).encode() + b"\n\n"
+
+
+def _chat_frames(identity: str, ending: Ending) -> tuple[bytes, ...]:
+ chunk: Final = {"id": identity, "object": "chat.completion.chunk", "created": 1, "model": _BACKEND}
+ content: Final = (
+ _data({**chunk, "choices": [{"index": 0, "delta": {"role": "assistant", "content": _PARTIAL[0]}}]}),
+ _data({**chunk, "choices": [{"index": 0, "delta": {"content": _PARTIAL[1]}, "finish_reason": None}]}),
+ )
+ match ending:
+ case "ending-complete":
+ finish: Final = _data({**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})
+ usage: Final = _data({**chunk, "choices": [], "usage": _USAGE})
+ return (*content, finish + usage + b"data: [DONE]\n\n")
+ case "ending-clean-eof":
+ return content
+ case "ending-done-without-finish":
+ return (*content, b"data: [DONE]\n\n")
+ case "ending-done-only":
+ return (b"data: [DONE]\n\n",)
+
+
+def _anthropic_frames(identity: str, ending: Ending) -> tuple[bytes, ...]:
+ content: Final = (message_start(identity, _CLAUDE), CONTENT_BLOCK_START, *map(text_delta, _PARTIAL))
+ return (*content, CONTENT_TAIL) if ending == "ending-complete" else content
+
+
+def _gemini_frames(ending: Ending) -> tuple[bytes, ...]:
+ """Gemini puts finishReason on the last text chunk and can follow it with a usage-only chunk."""
+ finished: Final = ending == "ending-complete"
+ content: Final = tuple(
+ _data(
+ {
+ "candidates": [
+ {
+ "content": {"parts": [{"text": part}], "role": "model"},
+ "index": 0,
+ **({"finishReason": "STOP"} if finished and part == _PARTIAL[-1] else {}),
+ }
+ ]
+ }
+ )
+ for part in _PARTIAL
+ )
+ usage: Final = _data({"usageMetadata": {"promptTokenCount": 9, "candidatesTokenCount": 7, "totalTokenCount": 16}})
+ return (*content, usage) if finished else content
+
+
+def _event(name: str, frame: JsonValue) -> bytes:
+ return f"event: {name}\n".encode() + _data(frame)
+
+
+def _langgraph_frames() -> tuple[bytes, ...]:
+ """A LangGraph server's run stream in its default mode, its run metadata first and then the state after each step"""
+ question: Final = {"type": "human", "content": "Answer in two steps."}
+ answers: Final = tuple({"type": "ai", "content": part} for part in _PARTIAL)
+ return (
+ _event("metadata", {"run_id": "run-1", "attempt": 1}),
+ *(_event("values", {"messages": [question, *answers[:step]]}) for step in range(len(answers) + 1)),
+ )
+
+
+def _agentcore_frames(ending: Ending) -> tuple[bytes, ...]:
+ """A runtime that forwards Bedrock Converse events without the metadata event, so its turn ends on messageStop"""
+ start: Final = _data({"event": {"messageStart": {"role": "assistant"}}})
+ content: Final = tuple(
+ _data({"event": {"contentBlockDelta": {"delta": {"text": part}, "contentBlockIndex": 0}}}) for part in _PARTIAL
+ )
+ end: Final = (
+ _data({"event": {"contentBlockStop": {"contentBlockIndex": 0}}}),
+ _data({"event": {"messageStop": {"stopReason": "end_turn"}}}),
+ )
+ return (start, *content, *end) if ending == "ending-complete" else (start, *content)
+
+
+def _frames(family: Family, identity: str, ending: Ending) -> tuple[bytes, ...]:
+ match family:
+ case "agentcore":
+ return _agentcore_frames(ending)
+ case "anthropic":
+ return _anthropic_frames(identity, ending)
+ case "gemini":
+ return _gemini_frames(ending)
+ case "langgraph":
+ return _langgraph_frames()
+ case "openai" | "hosted_vllm":
+ return _chat_frames(identity, ending)
+
+
+def _ending(request: Request) -> Ending:
+ endings: Final = [ending for ending in _ENDINGS if ending.encode() in request.body]
+ assert len(endings) == 1, request.body
+ return endings[0]
+
+
+def _upstream(family: Family, ending: Ending | None = None) -> Callable[[Request], Reply]:
+ def respond(request: Request) -> Reply:
+ assert request.method == "POST" and _TARGETS[family] in request.target, request.target
+ frames: Final = _frames(family, f"upstream-{uuid.uuid4().hex}", ending or _ending(request))
+ return Reply(content_type="text/event-stream", chunks=frames)
+
+ return answering_model_discovery(respond)
+
+
+def _deploy(scenario: Scenario, family: Family, wire: Wire) -> str:
+ if family == "agentcore":
+ return scenario.model(
+ model=_DEPLOYMENTS[family],
+ aws_bedrock_runtime_endpoint=wire.url,
+ aws_access_key_id="AKIAINTEGRATIONAGENT",
+ aws_secret_access_key="synthetic-agentcore-secret",
+ aws_region_name="us-west-2",
+ )
+ return scenario.model(model=_DEPLOYMENTS[family], api_base=wire.url, api_key=_PROVIDER_KEY)
+
+
+def _prompt(ending: Ending) -> str:
+ return f"Return JSON {ending} {uuid.uuid4().hex}"
+
+
+def _post(gateway: Gateway, path: str, body: Mapping[str, JsonValue]) -> tuple[str, str]:
+ response: Final = gateway.request("POST", path, {**body, "stream": True, "num_retries": 0})
+ assert response.status_code == 200, response.text
+ return response.text, response.headers[_CALL_ID_HEADER]
+
+
+def _chat(gateway: Gateway, model: str, ending: Ending) -> tuple[str, str]:
+ return _post(
+ gateway,
+ "/v1/chat/completions",
+ {
+ "model": model,
+ "messages": [{"role": "user", "content": _prompt(ending)}],
+ "stream_options": {"include_usage": True},
+ },
+ )
+
+
+def _choices(text: str) -> Iterator[Mapping[str, JsonValue]]:
+ for frame in data_frames(text):
+ yield from (choice for choice in frame.get("choices") or [] if isinstance(choice, dict))
+
+
+def _finish_reasons(text: str) -> tuple[str, ...]:
+ return tuple(string_value(choice["finish_reason"]) for choice in _choices(text) if choice.get("finish_reason"))
+
+
+def _chat_text(text: str) -> str:
+ deltas: Final = (choice.get("delta") for choice in _choices(text))
+ return "".join(string_value(delta.get("content") or "") for delta in deltas if isinstance(delta, dict))
+
+
+def _chat_errors(text: str) -> tuple[Mapping[str, JsonValue], ...]:
+ return tuple(frame for frame in data_frames(text) if "error" in frame)
+
+
+def _event_types(text: str) -> tuple[str, ...]:
+ return tuple(event for event, _ in sse_events(text))
+
+
+@pytest.mark.parametrize("family", ["openai", "anthropic", "agentcore"])
+def test_chat_stream_that_ends_cleanly_without_a_finish_reason_reaches_the_client_as_an_error(
+ gateway: Gateway, family: Family
+) -> None:
+ with wire_server(_upstream(family)) as wire, gateway.scenario() as scenario:
+ model: Final = _deploy(scenario, family, wire)
+ text, call_id = _chat(gateway, model, "ending-clean-eof")
+ assert _chat_text(text) == "".join(_PARTIAL), text
+ assert _finish_reasons(text) == (), text
+ assert len(_chat_errors(text)) == 1, text
+ assert spend_row(call_id)["status"] == "failure"
+
+
+def test_chat_stream_that_sends_done_without_a_finish_reason_reaches_the_client_as_an_error(gateway: Gateway) -> None:
+ with wire_server(_upstream("openai")) as wire, gateway.scenario() as scenario:
+ model: Final = scenario.model(model=_DEPLOYMENTS["openai"], api_base=wire.url, api_key=_PROVIDER_KEY)
+ text, call_id = _chat(gateway, model, "ending-done-without-finish")
+ assert _chat_text(text) == "".join(_PARTIAL), text
+ assert _finish_reasons(text) == (), text
+ assert len(_chat_errors(text)) == 1, text
+ assert spend_row(call_id)["status"] == "failure"
+
+
+def test_chat_stream_that_sends_only_done_still_completes_empty(gateway: Gateway) -> None:
+ """An upstream can answer with only [DONE]: Gemini's OpenAI-compatible endpoint did on 2026-10-20 when
+ reasoning used up max_tokens."""
+ with wire_server(_upstream("openai")) as wire, gateway.scenario() as scenario:
+ model: Final = scenario.model(model=_DEPLOYMENTS["openai"], api_base=wire.url, api_key=_PROVIDER_KEY)
+ text, _ = _chat(gateway, model, "ending-done-only")
+ assert _chat_text(text) == "", text
+ assert _finish_reasons(text) == ("stop",), text
+ assert _chat_errors(text) == (), text
+ assert text.rstrip().endswith("data: [DONE]"), text
+
+
+@pytest.mark.parametrize("family", ["openai", "anthropic", "gemini", "langgraph", "agentcore"])
+def test_chat_stream_with_a_provider_finish_reason_still_completes(gateway: Gateway, family: Family) -> None:
+ with wire_server(_upstream(family)) as wire, gateway.scenario() as scenario:
+ model: Final = _deploy(scenario, family, wire)
+ text, call_id = _chat(gateway, model, "ending-complete")
+ assert _chat_text(text) == "".join(_PARTIAL), text
+ assert _finish_reasons(text) == ("stop",), text
+ assert _chat_errors(text) == (), text
+ assert text.rstrip().endswith("data: [DONE]"), text
+ assert spend_row(call_id)["status"] == "success"
+
+
+def test_chat_stream_from_a_mock_response_deployment_still_completes(gateway: Gateway) -> None:
+ """mock_completion streams never carry a finish reason, so a mocked deployment must keep its synthetic stop."""
+ with gateway.scenario() as scenario:
+ model: Final = scenario.model(model=_DEPLOYMENTS["openai"], mock_response="".join(_PARTIAL))
+ text, call_id = _chat(gateway, model, "ending-clean-eof")
+ assert _chat_text(text) == "".join(_PARTIAL), text
+ assert _finish_reasons(text) == ("stop",), text
+ assert _chat_errors(text) == (), text
+ assert spend_row(call_id)["status"] == "success"
+
+
+@pytest.mark.parametrize(("ending", "terminal"), [("ending-complete", True), ("ending-clean-eof", False)])
+def test_messages_over_a_chat_bridged_deployment_reports_end_turn_only_for_a_finished_stream(
+ gateway: Gateway, ending: Ending, terminal: bool
+) -> None:
+ with wire_server(_upstream("hosted_vllm")) as wire, gateway.scenario() as scenario:
+ model: Final = scenario.model(model=_DEPLOYMENTS["hosted_vllm"], api_base=wire.url, api_key=_PROVIDER_KEY)
+ text, call_id = _post(
+ gateway,
+ "/v1/messages",
+ {"model": model, "max_tokens": 64, "messages": [{"role": "user", "content": _prompt(ending)}]},
+ )
+ types: Final = _event_types(text)
+ stop_reasons: Final = tuple(
+ data["delta"]["stop_reason"]
+ for event, data in sse_events(text)
+ if event == "message_delta" and isinstance(data.get("delta"), dict)
+ )
+ assert types[0] == "message_start", types
+ assert ("message_stop" in types) is terminal, types
+ assert ("error" in types) is not terminal, types
+ assert stop_reasons == (("end_turn",) if terminal else ()), types
+ assert spend_row(call_id)["status"] == ("success" if terminal else "failure")
+
+
+@pytest.mark.parametrize(("ending", "terminal"), [("ending-complete", True), ("ending-clean-eof", False)])
+def test_responses_over_a_chat_bridged_anthropic_deployment_completes_only_a_finished_stream(
+ gateway: Gateway, ending: Ending, terminal: bool
+) -> None:
+ with wire_server(_upstream("anthropic")) as wire, gateway.scenario() as scenario:
+ model: Final = scenario.model(model=_DEPLOYMENTS["anthropic"], api_base=wire.url, api_key=_PROVIDER_KEY)
+ text, call_id = _post(gateway, "/v1/responses", {"model": model, "input": _prompt(ending)})
+ types: Final = tuple(string_value(frame.get("type") or "") for frame in data_frames(text))
+ assert types[:1] == ("response.created",), text
+ assert ("response.completed" in types) is terminal, text
+ assert (types[-1] in {"error", "response.failed"}) is not terminal, text
+ assert spend_row(call_id)["status"] == ("success" if terminal else "failure")
+
+
+def _drain_openai_sdk(gateway: Gateway, model: str, sync_mode: bool) -> None:
+ messages: Final = [{"role": "user", "content": _prompt("ending-clean-eof")}]
+ if sync_mode:
+ for _ in openai_client(gateway).chat.completions.create(model=model, messages=messages, stream=True):
+ pass
+ return
+
+ async def drain() -> None:
+ stream: Final = await async_openai_client(gateway).chat.completions.create(
+ model=model, messages=messages, stream=True
+ )
+ async for _ in stream:
+ pass
+
+ asyncio.run(drain())
+
+
+def _drain_anthropic_sdk(gateway: Gateway, model: str, sync_mode: bool) -> None:
+ messages: Final = [{"role": "user", "content": _prompt("ending-clean-eof")}]
+ if sync_mode:
+ for _ in anthropic_client(gateway).messages.create(model=model, max_tokens=64, messages=messages, stream=True):
+ pass
+ return
+
+ async def drain() -> None:
+ stream: Final = await async_anthropic_client(gateway).messages.create(
+ model=model, max_tokens=64, messages=messages, stream=True
+ )
+ async for _ in stream:
+ pass
+
+ asyncio.run(drain())
+
+
+@pytest.mark.parametrize("sync_mode", [True, False])
+def test_openai_sdk_stream_cut_before_a_finish_reason_raises(gateway: Gateway, sync_mode: bool) -> None:
+ with wire_server(_upstream("openai")) as wire, gateway.scenario() as scenario:
+ model: Final = _deploy(scenario, "openai", wire)
+ with pytest.raises(openai.APIError, match="Stream ended before a finish_reason was received"):
+ _drain_openai_sdk(gateway, model, sync_mode)
+
+
+@pytest.mark.parametrize("sync_mode", [True, False])
+def test_anthropic_sdk_messages_stream_cut_before_a_finish_reason_raises(gateway: Gateway, sync_mode: bool) -> None:
+ with wire_server(_upstream("hosted_vllm")) as wire, gateway.scenario() as scenario:
+ model: Final = _deploy(scenario, "hosted_vllm", wire)
+ with pytest.raises(anthropic.APIError, match="Stream ended before a finish_reason was received"):
+ _drain_anthropic_sdk(gateway, model, sync_mode)
+
+
+def test_responses_stream_cut_on_its_deployment_finishes_on_the_request_fallback(gateway: Gateway) -> None:
+ with (
+ wire_server(_upstream("anthropic")) as primary,
+ wire_server(_upstream("anthropic", "ending-complete")) as backup,
+ gateway.scenario() as scenario,
+ ):
+ model: Final = _deploy(scenario, "anthropic", primary)
+ fallback: Final = _deploy(scenario, "anthropic", backup)
+ text, _ = _post(
+ gateway, "/v1/responses", {"model": model, "input": _prompt("ending-clean-eof"), "fallbacks": [fallback]}
+ )
+ types: Final = tuple(string_value(frame.get("type") or "") for frame in data_frames(text))
+ assert "response.completed" in types and "response.failed" not in types, text
+ assert (len(posted_targets(primary)), len(posted_targets(backup))) == (1, 1), text
+
+
+def test_chat_stream_cut_after_its_content_is_not_replayed_on_the_request_fallback(gateway: Gateway) -> None:
+ with (
+ wire_server(_upstream("openai")) as primary,
+ wire_server(_upstream("openai", "ending-complete")) as backup,
+ gateway.scenario() as scenario,
+ ):
+ model: Final = _deploy(scenario, "openai", primary)
+ fallback: Final = _deploy(scenario, "openai", backup)
+ text, _ = _post(
+ gateway,
+ "/v1/chat/completions",
+ {
+ "model": model,
+ "messages": [{"role": "user", "content": _prompt("ending-clean-eof")}],
+ "fallbacks": [fallback],
+ },
+ )
+ assert _chat_text(text) == "".join(_PARTIAL), text
+ assert len(_chat_errors(text)) == 1, text
+ assert posted_targets(backup) == (), text
+
+
+Route = Literal["/v1/chat/completions", "/v1/messages", "/v1/responses"]
+
+
+def _route_body(route: Route, model: str, ending: Ending) -> Mapping[str, JsonValue]:
+ match route:
+ case "/v1/chat/completions":
+ return {"model": model, "messages": [{"role": "user", "content": _prompt(ending)}]}
+ case "/v1/messages":
+ return {"model": model, "max_tokens": 64, "messages": [{"role": "user", "content": _prompt(ending)}]}
+ case "/v1/responses":
+ return {"model": model, "input": _prompt(ending)}
+
+
+def _finished(route: Route, text: str) -> bool:
+ match route:
+ case "/v1/chat/completions":
+ return _finish_reasons(text) == ("stop",) and _chat_errors(text) == ()
+ case "/v1/messages":
+ return "message_stop" in _event_types(text) and "error" not in _event_types(text)
+ case "/v1/responses":
+ return "response.completed" in tuple(string_value(frame.get("type") or "") for frame in data_frames(text))
+
+
+def _stream_outcome(gateway: Gateway, model: str, route: Route, ending: Ending) -> tuple[bool, str]:
+ text, call_id = _post(gateway, route, _route_body(route, model, ending))
+ return _finished(route, text), call_id
+
+
+_BRIDGED_FAMILY: Final[Mapping[Route, Family]] = {
+ "/v1/chat/completions": "openai",
+ "/v1/messages": "hosted_vllm",
+ "/v1/responses": "anthropic",
+}
+
+
+def test_concurrent_burst_ends_each_stream_by_whether_its_provider_finished_it(gateway: Gateway) -> None:
+ endings: Final[tuple[Ending, ...]] = ("ending-complete", "ending-clean-eof")
+ cases: Final = tuple(itertools.product(_BRIDGED_FAMILY, endings, range(5)))
+ with ExitStack() as wires, gateway.scenario() as scenario:
+ models: Final = {
+ route: _deploy(scenario, family, wires.enter_context(wire_server(_upstream(family))))
+ for route, family in _BRIDGED_FAMILY.items()
+ }
+ with ThreadPoolExecutor(max_workers=12) as pool:
+ outcomes: Final = tuple(
+ pool.map(lambda case: _stream_outcome(gateway, models[case[0]], case[0], case[1]), cases)
+ )
+ assert tuple(finished for finished, _ in outcomes) == tuple(
+ ending == "ending-complete" for _, ending, _ in cases
+ ), outcomes
+ assert tuple(spend_row(call_id)["status"] for _, call_id in outcomes) == tuple(
+ "success" if ending == "ending-complete" else "failure" for _, ending, _ in cases
+ )
+
+
+_SPEND_LOG_INSERT: Final = b'INSERT INTO "public"."LiteLLM_SpendLogs"'
+
+
+def _spend_statuses(database_url: str | None) -> Counter[tuple[str, str]]:
+ rows: Final = read_rows('SELECT litellm_call_id, status FROM "LiteLLM_SpendLogs"', (), database_url=database_url)
+ return Counter((string_value(row["litellm_call_id"]), string_value(row["status"])) for row in rows)
+
+
+def test_spend_log_outage_mid_burst_keeps_each_stream_ending_and_lands_every_row_once(
+ gateway: Gateway, tmp_path: Path
+) -> None:
+ endings: Final[tuple[Ending, ...]] = ("ending-complete", "ending-clean-eof")
+ cases: Final = tuple(itertools.product(_BRIDGED_FAMILY, endings))
+ with (
+ scratch_database() as database_url,
+ database_relay(database_url, _SPEND_LOG_INSERT) as (relay, relayed_url),
+ owned_proxy(gateway, tmp_path, {"DATABASE_URL": plaintext_database_url(relayed_url)}, workers=2) as candidate,
+ ExitStack() as wires,
+ candidate.scenario() as scenario,
+ ThreadPoolExecutor(max_workers=len(cases)) as pool,
+ ):
+ models: Final = {
+ route: _deploy(scenario, family, wires.enter_context(wire_server(_upstream(family))))
+ for route, family in _BRIDGED_FAMILY.items()
+ }
+
+ def burst() -> tuple[tuple[bool, str], ...]:
+ return tuple(pool.map(lambda case: _stream_outcome(candidate, models[case[0]], case[0], case[1]), cases))
+
+ relay.arm()
+ before: Final = burst()
+ assert relay.tripped.wait(90), "no spend log write reached the database after the first burst"
+ down: Final = not relay.reconnected.is_set()
+ during: Final = burst()
+ assert relay.reconnected.wait(90), "the proxy never reconnected to the database after the outage"
+ assert down and relay.refused >= 1, relay.refused
+ finished: Final = tuple(ending == "ending-complete" for _, ending in cases)
+ assert tuple(done for done, _ in before + during) == finished * 2, (before, during)
+ expected: Final = Counter(
+ (call_id, "success" if ending == "ending-complete" else "failure")
+ for (_, ending), (_, call_id) in zip(cases * 2, before + during, strict=True)
+ )
+ landed: Final = eventually(
+ lambda: _spend_statuses(database_url),
+ lambda rows: {call_id for call_id, _ in expected} <= {call_id for call_id, _ in rows},
+ seconds=120,
+ )
+ assert landed == expected
+
+
+def _attempt(gateway: Gateway, model: str, route: Route, ending: Ending) -> tuple[bool, str] | None:
+ """One stream's outcome, or None when the worker serving it was killed before the proxy finished the reply"""
+ try:
+ return _stream_outcome(gateway, model, route, ending)
+ except httpx.TransportError:
+ return None
+
+
+def test_worker_killed_mid_burst_leaves_the_proxy_ending_each_stream_by_whether_its_provider_finished_it(
+ gateway: Gateway, tmp_path: Path
+) -> None:
+ endings: Final[tuple[Ending, ...]] = ("ending-complete", "ending-clean-eof")
+ cases: Final = tuple(itertools.product(_BRIDGED_FAMILY, endings, range(4)))
+ probes: Final = tuple(itertools.product(_BRIDGED_FAMILY, endings))
+ with (
+ owned_proxy_process(gateway, tmp_path, {}, workers=2) as owned,
+ ExitStack() as wires,
+ owned.gateway.scenario() as scenario,
+ ):
+ candidate: Final = owned.gateway
+ models: Final = {
+ route: _deploy(scenario, family, wires.enter_context(wire_server(_upstream(family))))
+ for route, family in _BRIDGED_FAMILY.items()
+ }
+ workers: Final = eventually(owned.worker_pids, lambda pids: len(pids) == 2, seconds=60)
+ with owned.replacing_worker(min(workers)) as kill, ThreadPoolExecutor(max_workers=len(cases)) as pool:
+ futures: Final = [
+ pool.submit(_attempt, candidate, models[route], route, ending) for route, ending, _ in cases
+ ]
+ kill()
+ attempts: Final = tuple(future.result() for future in futures)
+ answered: Final = tuple(
+ (outcome[0], ending == "ending-complete")
+ for (_, ending, _), outcome in zip(cases, attempts, strict=True)
+ if outcome is not None
+ )
+ assert all(finished == expected for finished, expected in answered), attempts
+ after: Final = tuple(_stream_outcome(candidate, models[route], route, ending) for route, ending in probes)
+ assert tuple(finished for finished, _ in after) == tuple(ending == "ending-complete" for _, ending in probes)
+ statuses: Final = eventually(
+ lambda: _spend_statuses(candidate.database_url),
+ lambda rows: {call_id for _, call_id in after} <= {call_id for call_id, _ in rows},
+ seconds=120,
+ )
+ assert tuple(
+ statuses[(call_id, "success" if ending == "ending-complete" else "failure")]
+ for (_, ending), (_, call_id) in zip(probes, after, strict=True)
+ ) == (1,) * len(probes)
diff --git a/tests/unit/caching/test_request_redis_batch_pre_call.py b/tests/unit/caching/test_request_redis_batch_pre_call.py
index 37169e13a0b..94f19dbdc7c 100644
--- a/tests/unit/caching/test_request_redis_batch_pre_call.py
+++ b/tests/unit/caching/test_request_redis_batch_pre_call.py
@@ -290,7 +290,11 @@ def _router(redis_cache: FakeRedisCache, routing_strategy: str = "usage-based-ro
@pytest.mark.asyncio
-async def test_armed_routing_read_rides_the_admission_pipeline_and_routing_issues_no_read_of_its_own():
+async def test_armed_routing_read_rides_the_admission_pipeline_and_routing_issues_no_read_of_its_own(monkeypatch):
+ monkeypatch.setattr(
+ "litellm.router_strategy.lowest_tpm_rpm_v2.get_utc_datetime",
+ lambda: datetime(2026, 1, 1, 12, 0, 30, tzinfo=timezone.utc),
+ )
client = FakeClient(_lua_ok_replies)
redis_cache = FakeRedisCache(client)
router = _router(redis_cache)
diff --git a/tests/unit/integrations/test_anthropic_cache_control_hook.py b/tests/unit/integrations/test_anthropic_cache_control_hook.py
index fa03879c05a..778c8dfe2f5 100644
--- a/tests/unit/integrations/test_anthropic_cache_control_hook.py
+++ b/tests/unit/integrations/test_anthropic_cache_control_hook.py
@@ -1116,7 +1116,7 @@ def test_cache_control_hook_counts_tool_call_cache_controls():
assert AnthropicCacheControlHook.count_request_cache_breakpoints([message]) == 3
-def test_cache_control_hook_counts_tool_call_marks_except_answered_server_tool_calls():
+def test_cache_control_hook_counts_tool_call_marks_except_server_tool_calls_the_client_did_not_answer():
message: Final[AllMessageValues] = {
"role": "assistant",
"content": None,
@@ -1170,7 +1170,7 @@ def test_cache_control_hook_counts_tool_call_marks_except_answered_server_tool_c
},
}
- assert AnthropicCacheControlHook.count_request_cache_breakpoints([message]) == 2
+ assert AnthropicCacheControlHook.count_request_cache_breakpoints([message]) == 1
_SEARCH_ANSWER: Final = {"role": "tool", "tool_call_id": "srvtoolu_01Search", "content": "found get_weather"}
@@ -1182,6 +1182,12 @@ _SEARCH_CALL: Final = {
"function": {"name": "tool_search_tool_bm25", "arguments": "{}"},
"cache_control": {"type": "ephemeral"},
}
+_UNRUN_WEB_SEARCH_CALL: Final = {
+ "id": "srvtoolu_01Web",
+ "type": "function",
+ "function": {"name": "web_search", "arguments": "{}"},
+ "cache_control": {"type": "ephemeral"},
+}
_WEATHER_CALL: Final = {
"id": "toolu_01Weather",
"type": "function",
@@ -1238,6 +1244,8 @@ def _replayed_search_call(*tool_calls: dict[str, object]) -> AllMessageValues:
(_SEARCH_CALL,), (_SEARCH_ANSWER, _GO_ON), False, 0, id="client_answer_replaced_by_the_kept_result"
),
pytest.param((_SEARCH_CALL,), (_GO_ON,), True, 0, id="placeholder_replaced_by_the_kept_result"),
+ pytest.param((_UNRUN_WEB_SEARCH_CALL,), (), False, 0, id="trailing_unrun_search_replayed_without_its_mark"),
+ pytest.param((_UNRUN_WEB_SEARCH_CALL,), (_GO_ON,), False, 0, id="unrun_search_dropped_before_a_new_message"),
pytest.param(
(_SEARCH_CALL, _WEATHER_CALL),
(_SEARCH_ANSWER,),
@@ -5034,3 +5042,50 @@ def test_cache_control_hook_injects_next_to_a_malformed_message_instead_of_raisi
{"type": "ephemeral"},
{"type": "ephemeral"},
]
+
+
+def _search_conversation_with(follow_up: dict[str, object]) -> list[AllMessageValues]:
+ return [
+ cast(AllMessageValues, {"role": "user", "content": "Weather in Paris?"}),
+ _replayed_search_call(_SEARCH_CALL),
+ cast(AllMessageValues, follow_up),
+ cast(AllMessageValues, _GO_ON),
+ ]
+
+
+def _cache_marks_after_injection(messages: list[AllMessageValues]) -> tuple[int, tuple[int, ...]]:
+ _, processed, _ = AnthropicCacheControlHook().get_chat_completion_prompt(
+ model="anthropic/claude-opus-5-5",
+ messages=messages,
+ non_default_params={"cache_control_injection_points": [{"location": "message", "role": "user"}]},
+ prompt_id=None,
+ prompt_variables=None,
+ dynamic_callback_params={},
+ )
+ return (
+ AnthropicCacheControlHook.count_request_cache_breakpoints(messages),
+ tuple(_marks_in(message) for message in processed),
+ )
+
+
+@pytest.mark.parametrize(
+ ("malformed", "well_formed"),
+ [
+ pytest.param(
+ {**_SEARCH_ANSWER, "tool_call_id": {"id": "srvtoolu_01Search"}},
+ {**_SEARCH_ANSWER, "tool_call_id": "toolu_01Unrelated"},
+ id="object_tool_call_id",
+ ),
+ pytest.param(
+ {**_SEARCH_ANSWER, "tool_call_id": ["srvtoolu_01Search"]},
+ {**_SEARCH_ANSWER, "tool_call_id": "toolu_01Unrelated"},
+ id="list_tool_call_id",
+ ),
+ ],
+)
+def test_cache_control_hook_reads_a_non_string_answer_id_as_answering_no_tool_call(
+ malformed: dict[str, object], well_formed: dict[str, object]
+):
+ assert _cache_marks_after_injection(_search_conversation_with(malformed)) == _cache_marks_after_injection(
+ _search_conversation_with(well_formed)
+ )
diff --git a/tests/unit/litellm_core_utils/test_core_helpers.py b/tests/unit/litellm_core_utils/test_core_helpers.py
index 1a92aa7c2c1..b21e9eb8b5a 100644
--- a/tests/unit/litellm_core_utils/test_core_helpers.py
+++ b/tests/unit/litellm_core_utils/test_core_helpers.py
@@ -319,6 +319,12 @@ class TestMapFinishReasonCohere:
def test_error(self):
assert map_finish_reason("ERROR") == "stop"
+ def test_tool_call(self):
+ assert map_finish_reason("TOOL_CALL") == "tool_calls"
+
+ def test_stop_sequence(self):
+ assert map_finish_reason("STOP_SEQUENCE") == "stop"
+
class TestMapFinishReasonHuggingFace:
def test_eos_token(self):
diff --git a/tests/unit/litellm_core_utils/test_json_fragment_accumulator.py b/tests/unit/litellm_core_utils/test_json_fragment_accumulator.py
index f27a5fd2307..212dff79daf 100644
--- a/tests/unit/litellm_core_utils/test_json_fragment_accumulator.py
+++ b/tests/unit/litellm_core_utils/test_json_fragment_accumulator.py
@@ -1,6 +1,5 @@
import json
import time
-import tracemalloc
from collections.abc import Iterator
from typing import Final
from unittest.mock import patch
@@ -193,34 +192,15 @@ def _streamed(fragments: tuple[str, ...]) -> JSONFragmentAccumulator:
return accumulator
-def _drain_peak_bytes(fragments: tuple[str, ...]) -> int:
- accumulator: Final = _streamed(fragments)
- first_found, _ = accumulator.pop_next_value()
- assert first_found is True
- already_tracing: Final = tracemalloc.is_tracing()
- if not already_tracing:
- tracemalloc.start()
- tracemalloc.reset_peak()
- baseline, _ = tracemalloc.get_traced_memory()
- try:
- drained: Final = sum(1 for _ in _popped_values(accumulator))
- _, peak = tracemalloc.get_traced_memory()
- finally:
- if not already_tracing:
- tracemalloc.stop()
- assert drained == len(fragments) - 1
- return peak - baseline
-
-
-def test_draining_many_concatenated_values_never_copies_the_remainder():
+def test_draining_many_concatenated_values_yields_every_value_in_order():
value: Final = '{"text": "' + "x" * 20 + '"}'
fragments: Final = tuple(value if index % 2 else f" {value}" for index in range(40_000))
- payload_bytes: Final = sum(len(fragment) for fragment in fragments)
- peak_bytes: Final = _drain_peak_bytes(fragments)
- assert peak_bytes < payload_bytes // 4, (
- f"draining {len(fragments)} values allocated a peak of {peak_bytes} bytes over a {payload_bytes} byte payload, "
- "copying the remainder on every pop?"
- )
+ accumulator: Final = _streamed(fragments)
+
+ drained: Final = tuple(_popped_values(accumulator))
+
+ assert drained == (json.loads(value),) * len(fragments)
+ assert accumulator.pop_next_value() == (False, None)
def test_could_close_json_after_many_blank_fragments_is_not_quadratic():
diff --git a/tests/unit/litellm_core_utils/test_litellm_logging.py b/tests/unit/litellm_core_utils/test_litellm_logging.py
index 93a0b0735da..f1854563cd5 100644
--- a/tests/unit/litellm_core_utils/test_litellm_logging.py
+++ b/tests/unit/litellm_core_utils/test_litellm_logging.py
@@ -2946,6 +2946,44 @@ async def test_async_success_handler_runs_async_callbacks_in_the_post_response_p
assert in_post_response_phase() is False, "the phase must not leak into the request task"
+@pytest.mark.parametrize("stream", [True, False], ids=["streaming", "non_streaming"])
+def test_reused_logging_obj_does_not_report_the_previous_attempts_first_token(logging_obj, stream: bool):
+ """A fallback reuses the request's logging object but runs in its own window.
+
+ When the first attempt already recorded a first-token time, the fallback must not
+ report it. Otherwise ``completion_start_time`` lands before the attempt's
+ ``start_time``, the callbacks see a negative TTFT, and latency-based routing caches
+ a negative sample that empties its buffer window.
+ """
+ logging_obj.stream = stream
+ logging_obj.model_call_details["litellm_params"] = {}
+ logging_obj.litellm_params = {}
+
+ first_attempt_start: Final = datetime.datetime(2026, 1, 1, 12, 0, 0)
+ logging_obj.update_completion_start_time(first_attempt_start + datetime.timedelta(seconds=1))
+ fallback_start: Final = first_attempt_start + datetime.timedelta(seconds=600)
+ fallback_end: Final = fallback_start + datetime.timedelta(seconds=5)
+
+ observed: dict[str, object] = {}
+
+ class Recorder(CustomLogger):
+ def log_success_event(self, kwargs, response_obj, start_time, end_time) -> None:
+ observed["completion_start_time"] = kwargs.get("completion_start_time")
+
+ with patch.object(logging_obj, "get_combined_callback_list", return_value=[Recorder()]):
+ logging_obj.success_handler(
+ result=_success_response(),
+ start_time=fallback_start,
+ end_time=fallback_end,
+ )
+
+ completion_start_time: Final = observed["completion_start_time"]
+ assert isinstance(completion_start_time, datetime.datetime)
+ assert completion_start_time >= fallback_start, (
+ "the fallback attempt reported a first-token time that predates its own start"
+ )
+
+
def test_is_sync_litellm_request():
assert LitellmLogging.is_sync_litellm_request({}) is True
assert LitellmLogging.is_sync_litellm_request({"acompletion": True}) is False
diff --git a/tests/unit/litellm_core_utils/test_streaming_handler.py b/tests/unit/litellm_core_utils/test_streaming_handler.py
index 365ed3452cf..5c0c1642e01 100644
--- a/tests/unit/litellm_core_utils/test_streaming_handler.py
+++ b/tests/unit/litellm_core_utils/test_streaming_handler.py
@@ -32,8 +32,12 @@ from litellm.litellm_core_utils.streaming_handler import (
_ProviderChunkEarlyReturn,
_ProviderChunkParsed,
)
+from litellm.llms.anthropic.chat.handler import ModelResponseIterator as AnthropicModelResponseIterator
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.openai.chat.gpt_transformation import OpenAIChatCompletionStreamingHandler
+from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
+ ModelResponseIterator as GeminiModelResponseIterator,
+)
from litellm.responses.mcp.chat_completions_handler import _add_mcp_metadata_to_response
from litellm.types.utils import (
ChoiceLogprobs,
@@ -572,11 +576,6 @@ _GET_WEATHER_CALL: Final = {
"abcd",
0,
),
- (
- (_stream_chunk({"reasoning_content": "Still thinking"}),),
- "Still thinking",
- 0,
- ),
(
(_stream_chunk({"content": "hi"}), _stream_chunk({}, "stop")),
"hi",
@@ -588,7 +587,6 @@ _GET_WEATHER_CALL: Final = {
"reasoning_then_function_call",
"reasoning_cut_by_length",
"reasoning_after_answer",
- "stream_ends_mid_reasoning",
"answer_without_reasoning",
],
)
@@ -622,6 +620,26 @@ async def test_merged_reasoning_stream_keeps_think_tags_balanced(
)
+@pytest.mark.asyncio
+@pytest.mark.parametrize("sync_mode", [True, False])
+async def test_merged_reasoning_stream_cut_mid_reasoning_fails_instead_of_closing_the_think_block(
+ logging_obj: Logging, sync_mode: bool
+) -> None:
+ response: Final = CustomStreamWrapper(
+ completion_stream=ModelResponseListIterator(
+ model_responses=[_stream_chunk({"reasoning_content": "Still thinking"})]
+ ),
+ model="deepseek-reasoner",
+ logging_obj=logging_obj,
+ )
+ response.merge_reasoning_content_in_choices = True
+
+ with pytest.raises(MidStreamFallbackError) as excinfo:
+ await _collect(response, sync_mode)
+
+ assert excinfo.value.generated_content == "Still thinking"
+
+
def test_strip_sse_data_from_chunk():
"""Test the static method that strips 'data: ' prefix from SSE chunks"""
# Test with string inputs
@@ -2270,6 +2288,242 @@ async def test_mid_stream_error_keeps_credentials_in_the_api_base_out(routed_api
assert "deployment-secret" not in debug_info
+def _incomplete_openai_chat_chunk() -> ModelResponseStream:
+ return ModelResponseStream(
+ id="chatcmpl-incomplete-eof",
+ created=1,
+ model="gpt-5.6",
+ object="chat.completion.chunk",
+ choices=[
+ StreamingChoices(
+ index=0,
+ delta=Delta(content='{"findings":[{"title":"unfinished', role="assistant"),
+ finish_reason=None,
+ )
+ ],
+ )
+
+
+@pytest.mark.parametrize("custom_llm_provider", ["openai", "azure", "hosted_vllm"])
+@pytest.mark.asyncio
+async def test_async_openai_compatible_chat_clean_eof_without_finish_reason_raises_midstream_fallback(
+ logging_obj: Logging, custom_llm_provider: str
+) -> None:
+ """An OpenAI-compatible stream without a provider finish reason must fail at clean EOF."""
+
+ logging_obj.model_call_details["custom_llm_provider"] = custom_llm_provider
+ response: Final = CustomStreamWrapper(
+ completion_stream=_as_async(iter([_incomplete_openai_chat_chunk()])),
+ model="gpt-5.6",
+ logging_obj=logging_obj,
+ custom_llm_provider=custom_llm_provider,
+ stream_options={"include_usage": True},
+ )
+
+ first_chunk: Final = await response.__anext__()
+ with pytest.raises(MidStreamFallbackError) as excinfo:
+ await response.__anext__()
+
+ assert first_chunk.choices[0].delta.content == '{"findings":[{"title":"unfinished'
+ assert excinfo.value.generated_content == '{"findings":[{"title":"unfinished'
+ assert excinfo.value.is_pre_first_chunk is False
+ assert response.received_finish_reason is None
+
+
+@pytest.mark.parametrize("custom_llm_provider", ["openai", "azure", "hosted_vllm"])
+def test_sync_openai_compatible_chat_clean_eof_without_finish_reason_raises_midstream_fallback(
+ logging_obj: Logging, custom_llm_provider: str
+) -> None:
+ """The synchronous OpenAI-compatible stream must fail at clean EOF too."""
+
+ logging_obj.model_call_details["custom_llm_provider"] = custom_llm_provider
+ response: Final = CustomStreamWrapper(
+ completion_stream=iter([_incomplete_openai_chat_chunk()]),
+ model="gpt-5.6",
+ logging_obj=logging_obj,
+ custom_llm_provider=custom_llm_provider,
+ stream_options={"include_usage": True},
+ )
+
+ first_chunk: Final = next(response)
+ with pytest.raises(MidStreamFallbackError) as excinfo:
+ next(response)
+
+ assert first_chunk.choices[0].delta.content == '{"findings":[{"title":"unfinished'
+ assert excinfo.value.generated_content == '{"findings":[{"title":"unfinished'
+ assert excinfo.value.is_pre_first_chunk is False
+ assert response.received_finish_reason is None
+
+
+async def _collect(wrapper: CustomStreamWrapper, sync_mode: bool) -> list[ModelResponseStream]:
+ if sync_mode:
+ return list(wrapper)
+ return [chunk async for chunk in wrapper]
+
+
+@pytest.mark.parametrize("custom_llm_provider", ["openai", "azure", "hosted_vllm"])
+@pytest.mark.parametrize("sync_mode", [True, False])
+@pytest.mark.asyncio
+async def test_stream_that_sends_no_chunks_keeps_the_default_stop(
+ logging_obj: Logging, custom_llm_provider: str, sync_mode: bool
+) -> None:
+ """Gemini's OpenAI-compatible endpoint answers with only [DONE] when reasoning uses up max_tokens."""
+
+ wrapper: Final = CustomStreamWrapper(
+ completion_stream=iter(()) if sync_mode else _as_async(iter(())),
+ model="gemini-2.5-flash",
+ logging_obj=logging_obj,
+ custom_llm_provider=custom_llm_provider,
+ stream_options={"include_usage": True},
+ )
+
+ received: Final = await _collect(wrapper, sync_mode)
+
+ assert [c.choices[0].finish_reason for c in received if c.choices and c.choices[0].finish_reason] == ["stop"]
+
+
+_ANTHROPIC_PARTIAL_EVENTS: Final[tuple[dict[str, object], ...]] = (
+ {
+ "type": "message_start",
+ "message": {
+ "id": "msg_incomplete_eof",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-4-5",
+ "content": [],
+ "stop_reason": None,
+ "usage": {"input_tokens": 9, "output_tokens": 1},
+ },
+ },
+ {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
+ {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {"type": "text_delta", "text": '{"findings":[{"title":"unfinished'},
+ },
+)
+_ANTHROPIC_TERMINAL_EVENTS: Final[tuple[dict[str, object], ...]] = (
+ {"type": "content_block_stop", "index": 0},
+ {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 7}},
+ {"type": "message_stop"},
+)
+
+
+def _anthropic_sse_lines(events: Sequence[Mapping[str, object]]) -> list[str]:
+ return [line for event in events for line in (f"event: {event['type']}", f"data: {json.dumps(event)}", "")]
+
+
+def _anthropic_stream(
+ events: Sequence[Mapping[str, object]], sync_mode: bool, logging_obj: Logging
+) -> CustomStreamWrapper:
+ lines: Final = _anthropic_sse_lines(events)
+
+ return CustomStreamWrapper(
+ completion_stream=AnthropicModelResponseIterator(
+ streaming_response=iter(lines) if sync_mode else _as_async(iter(lines)), sync_stream=sync_mode
+ ),
+ model="claude-sonnet-4-5",
+ custom_llm_provider="anthropic",
+ logging_obj=logging_obj,
+ )
+
+
+@pytest.mark.parametrize("sync_mode", [True, False])
+@pytest.mark.asyncio
+async def test_anthropic_stream_that_ends_before_message_delta_fails_with_the_eof_message(
+ sync_mode: bool, logging_obj: Logging
+) -> None:
+ wrapper: Final = _anthropic_stream(_ANTHROPIC_PARTIAL_EVENTS, sync_mode, logging_obj)
+
+ with pytest.raises(MidStreamFallbackError) as excinfo:
+ await _collect(wrapper, sync_mode)
+
+ assert excinfo.value.generated_content == '{"findings":[{"title":"unfinished'
+ assert excinfo.value.is_pre_first_chunk is False
+ assert isinstance(excinfo.value.original_exception, litellm.APIConnectionError)
+ assert "AnthropicException - Stream ended before a finish_reason was received" in str(excinfo.value)
+ assert wrapper.received_finish_reason is None
+
+
+@pytest.mark.parametrize("sync_mode", [True, False])
+@pytest.mark.asyncio
+async def test_anthropic_stream_with_message_stop_ends_with_its_finish_reason(
+ sync_mode: bool, logging_obj: Logging
+) -> None:
+ wrapper: Final = _anthropic_stream(
+ (*_ANTHROPIC_PARTIAL_EVENTS, *_ANTHROPIC_TERMINAL_EVENTS), sync_mode, logging_obj
+ )
+
+ received: Final = await _collect(wrapper, sync_mode)
+
+ assert [c.choices[0].finish_reason for c in received if c.choices and c.choices[0].finish_reason] == ["stop"]
+ assert "".join(c.choices[0].delta.content or "" for c in received if c.choices) == (
+ '{"findings":[{"title":"unfinished'
+ )
+
+
+@pytest.mark.parametrize("sync_mode", [True, False])
+@pytest.mark.asyncio
+async def test_generic_chunk_stream_without_a_finish_reason_keeps_the_default_stop(
+ sync_mode: bool, logging_obj: Logging
+) -> None:
+ """Providers that stream GenericStreamingChunks and never mark the last one finished still end with stop,
+ but the logged response built from ``chunks`` never gains that synthetic stop."""
+ chunks: Final = [
+ {"text": text, "tool_use": None, "is_finished": False, "finish_reason": "", "usage": None, "index": 0}
+ for text in ("partial", " reply")
+ ]
+ wrapper: Final = CustomStreamWrapper(
+ completion_stream=ModelResponseListIterator(model_responses=chunks),
+ model="sagemaker/tgi-endpoint",
+ custom_llm_provider="sagemaker",
+ logging_obj=logging_obj,
+ )
+
+ received: Final = await _collect(wrapper, sync_mode)
+
+ assert [c.choices[0].finish_reason for c in received if c.choices and c.choices[0].finish_reason] == ["stop"]
+ assert "".join(c.choices[0].delta.content or "" for c in received if c.choices) == "partial reply"
+ assert all(not (c.choices and c.choices[0].finish_reason) for c in wrapper.chunks)
+
+
+@pytest.mark.parametrize("sync_mode", [True, False])
+@pytest.mark.asyncio
+async def test_gemini_stream_with_a_usage_chunk_after_its_finish_reason_still_completes(
+ sync_mode: bool, logging_obj: Logging
+) -> None:
+ """Gemini marks every chunk unfinished, so its finishReason only shows up mid-stream before the usage chunk."""
+ lines: Final = [
+ f"data: {json.dumps(event)}"
+ for event in (
+ {"candidates": [{"content": {"parts": [{"text": "Hello"}], "role": "model"}, "index": 0}]},
+ {
+ "candidates": [
+ {"content": {"parts": [{"text": " there"}], "role": "model"}, "finishReason": "STOP", "index": 0}
+ ]
+ },
+ {"usageMetadata": {"promptTokenCount": 3, "candidatesTokenCount": 2, "totalTokenCount": 5}},
+ )
+ ]
+
+ logging_obj.update_environment_variables(
+ model="gemini-2.5-flash", user=None, optional_params={}, litellm_params={"litellm_call_id": "gemini-tail"}
+ )
+ iterator: Final = GeminiModelResponseIterator(
+ streaming_response=iter(lines) if sync_mode else _as_async(iter(lines)), sync_stream=sync_mode, logging_obj=logging_obj
+ )
+ wrapper: Final = CustomStreamWrapper(
+ completion_stream=iter(iterator) if sync_mode else iterator,
+ model="gemini-2.5-flash",
+ custom_llm_provider="gemini",
+ logging_obj=logging_obj,
+ )
+
+ received: Final = await _collect(wrapper, sync_mode)
+
+ assert "".join(c.choices[0].delta.content or "" for c in received if c.choices) == "Hello there"
+ assert [c.choices[0].finish_reason for c in received if c.choices and c.choices[0].finish_reason] == ["stop"]
+
def test_streaming_handler_with_created_time_propagation(
initialized_custom_stream_wrapper: CustomStreamWrapper, logging_obj: Logging
@@ -3638,11 +3892,8 @@ def test_usage_chunk_after_finish_reason_updates_hidden_params(logging_obj):
@pytest.mark.parametrize("sync_mode", [True, False])
-@pytest.mark.parametrize("ends_with_finish_chunk", [True, False])
@pytest.mark.asyncio
-async def test_stream_without_provider_usage_falls_back_to_the_token_estimate(
- sync_mode: bool, ends_with_finish_chunk: bool
-) -> None:
+async def test_stream_without_provider_usage_falls_back_to_the_token_estimate(sync_mode: bool) -> None:
model: Final = "gpt-4o"
messages: Final = [{"role": "user", "content": "Write a long sentence about a fox. " * 30}]
text: Final = "The quick brown fox jumps over the lazy dog. " * 20
@@ -3659,9 +3910,7 @@ async def test_stream_without_provider_usage_falls_back_to_the_token_estimate(
choices=[StreamingChoices(index=0, delta=Delta(content=""), finish_reason="stop")],
)
wrapper: Final = CustomStreamWrapper(
- completion_stream=ModelResponseListIterator(
- model_responses=[content_chunk, finish_chunk] if ends_with_finish_chunk else [content_chunk]
- ),
+ completion_stream=ModelResponseListIterator(model_responses=[content_chunk, finish_chunk]),
model=model,
custom_llm_provider="openai",
logging_obj=Logging(
@@ -3700,6 +3949,55 @@ async def test_stream_without_provider_usage_falls_back_to_the_token_estimate(
assert litellm.completion_cost(completion_response=assembled) == pytest.approx(spend)
+@pytest.mark.parametrize("sync_mode", [True, False])
+@pytest.mark.asyncio
+async def test_stream_cut_before_a_finish_chunk_bills_the_token_estimate_on_its_failure(sync_mode: bool) -> None:
+ model: Final = "gpt-4o"
+ messages: Final = [{"role": "user", "content": "Write a long sentence about a fox. " * 30}]
+ text: Final = "The quick brown fox jumps over the lazy dog. " * 20
+ logging_obj: Final = Logging(
+ model=model,
+ messages=messages,
+ stream=True,
+ call_type="completion",
+ start_time=datetime.datetime(2025, 3, 3, 21, 38, 10),
+ litellm_call_id="cut-no-usage-call",
+ function_id="cut-no-usage-fn",
+ )
+ logging_obj.update_environment_variables(
+ model=model, user=None, optional_params={}, litellm_params={"litellm_call_id": "cut-no-usage-call"}
+ )
+ wrapper: Final = CustomStreamWrapper(
+ completion_stream=ModelResponseListIterator(
+ model_responses=[
+ ModelResponseStream(
+ id="chatcmpl-cut-no-usage",
+ created=1741037890,
+ model=model,
+ choices=[StreamingChoices(index=0, delta=Delta(role="assistant", content=text))],
+ )
+ ]
+ ),
+ model=model,
+ custom_llm_provider="openai",
+ logging_obj=logging_obj,
+ stream_options=None,
+ )
+
+ with pytest.raises(MidStreamFallbackError):
+ await _collect(wrapper, sync_mode)
+
+ expected: Final = (
+ litellm.token_counter(model=model, messages=messages),
+ litellm.token_counter(model=model, text=text, count_response_tokens=True),
+ )
+ usage: Final = logging_obj.model_call_details["combined_usage_object"]
+ assert (usage.prompt_tokens, usage.completion_tokens) == expected
+ rates: Final = litellm.model_cost[model]
+ spend: Final = expected[0] * rates["input_cost_per_token"] + expected[1] * rates["output_cost_per_token"]
+ assert logging_obj.model_call_details["response_cost"] == pytest.approx(spend)
+
+
@pytest.mark.parametrize("sync_mode", [True, False])
@pytest.mark.asyncio
async def test_stream_with_usage_after_the_finish_chunk_bills_only_the_reported_usage(sync_mode: bool) -> None:
@@ -8582,10 +8880,8 @@ async def test_no_synthetic_finish_reason_logged_when_provider_sent_none(sync_mo
custom_llm_provider="bedrock",
logging_obj=logging_obj,
)
- if sync_mode:
- list(response)
- else:
- [c async for c in response]
+ with pytest.raises(MidStreamFallbackError):
+ await _collect(response, sync_mode)
assert response.received_finish_reason is None
assert all(not (c.choices and c.choices[0].finish_reason) for c in response.chunks)
diff --git a/tests/unit/llms/a2a/test_common_utils.py b/tests/unit/llms/a2a/test_common_utils.py
index 6047edb3f4f..8f433cdcdb7 100644
--- a/tests/unit/llms/a2a/test_common_utils.py
+++ b/tests/unit/llms/a2a/test_common_utils.py
@@ -2,6 +2,7 @@
from collections.abc import Mapping
from types import MappingProxyType
+from typing import Final
import pytest
@@ -50,3 +51,42 @@ async def test_agent_without_entra_credentials_gets_no_bearer():
assert header is None
assert resolver.calls == []
+
+
+@pytest.mark.parametrize(
+ ("api_key", "expected"),
+ (
+ ("secret", "Bearer secret"),
+ ("Bearer secret", "Bearer Bearer secret"),
+ ("os.environ/SYNTHETIC_KEY", "Bearer os.environ/SYNTHETIC_KEY"),
+ ("", None),
+ (None, None),
+ (42, None),
+ ),
+)
+async def test_plain_agent_key_is_used_verbatim_only_for_nonempty_strings(
+ api_key: object, expected: str | None
+) -> None:
+ header: Final = await resolve_a2a_hop_auth_header({"api_key": api_key}, None)
+ assert header == ({"Authorization": expected} if expected is not None else None)
+
+
+@pytest.mark.parametrize("provider", ("azure_ai", "databricks_agent"))
+async def test_completion_bridge_api_key_is_not_used_for_a2a_hop(provider: str) -> None:
+ assert await resolve_a2a_hop_auth_header({"api_key": "secret"}, provider) is None
+
+
+async def test_entra_bearer_overrides_agent_key_and_passthrough() -> None:
+ resolver: Final = _RecordingEntraResolver()
+ params: Final = {**_SERVICE_PRINCIPAL, "api_key": "secret"}
+ header: Final = await resolve_a2a_hop_auth_header(params, None, resolver, {"authorization": "Bearer forwarded"})
+ assert header == {"Authorization": "Bearer minted-entra-token"}
+ assert resolver.calls == [params]
+
+
+@pytest.mark.parametrize("name", ("Authorization", "aUtHoRiZaTiOn"))
+async def test_passthrough_authorization_takes_precedence_over_plain_agent_key(name: str) -> None:
+ assert (
+ await resolve_a2a_hop_auth_header({"api_key": "secret"}, None, agent_extra_headers={name: "Bearer forwarded"})
+ is None
+ )
diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py
index c8a2f783809..636e23168cf 100644
--- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py
+++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py
@@ -25,7 +25,7 @@ from litellm.constants import (
from litellm.litellm_core_utils.prompt_templates.common_utils import encrypted_reasoning_signature
from litellm.litellm_core_utils.prompt_templates.factory import anthropic_messages_pt
from litellm.llms.anthropic.chat import ModelResponseIterator
-from litellm.llms.anthropic.common_utils import process_anthropic_headers
+from litellm.llms.anthropic.common_utils import AnthropicError, process_anthropic_headers
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
from litellm.llms.base_llm.base_utils import ResponseFormatSchema, response_format_schema
from litellm.llms.anthropic.messages.native.transformation import (
@@ -9922,6 +9922,382 @@ def test_transform_request_tool_config_point_without_tools_drops_the_key():
assert litellm_params["metadata"] == {}
+_KEPT_SEARCH_CALL: Final = {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ {
+ "id": "srvtoolu_01Search",
+ "type": "function",
+ "function": {"name": "tool_search_tool_bm25", "arguments": "{}"},
+ }
+ ],
+ "provider_specific_fields": {
+ "tool_results": [
+ {
+ "type": "tool_search_tool_result",
+ "tool_use_id": "srvtoolu_01Search",
+ "content": {"type": "tool_search_tool_search_result", "tool_references": []},
+ }
+ ]
+ },
+}
+_WEATHER_CALL: Final = {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [{"id": "toolu_01", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}],
+}
+_ASK: Final = {"role": "user", "content": "Weather in Paris?"}
+
+
+def _search_answered_with(tool_call_id: object) -> list[dict[str, object]]:
+ return [
+ _ASK,
+ _KEPT_SEARCH_CALL,
+ {"role": "tool", "tool_call_id": tool_call_id, "content": "found get_weather"},
+ {"role": "user", "content": "Go on"},
+ ]
+
+
+def _orphan_answer_with(tool_call_id: object) -> list[dict[str, object]]:
+ return [
+ _ASK,
+ {"role": "assistant", "content": "Checking"},
+ {"role": "tool", "tool_call_id": tool_call_id, "content": "sunny"},
+ {"role": "user", "content": "Go on"},
+ ]
+
+
+@pytest.mark.parametrize(
+ ("messages", "field_path", "type_name"),
+ [
+ pytest.param(
+ [
+ {"role": "user", "content": "Hi"},
+ {"role": "assistant", "content": "Hello"},
+ {"role": ["user"], "content": "Go on"},
+ {"role": "system", "content": "Answer in one sentence."},
+ ],
+ "messages[2].role",
+ "list",
+ id="list_role_before_a_later_system_message",
+ ),
+ pytest.param(
+ [
+ {"role": "user", "content": "Hi"},
+ {"role": "assistant", "content": "Hello"},
+ {"role": ["user"], "content": "Go on"},
+ ],
+ "messages[2].role",
+ "list",
+ id="list_role",
+ ),
+ pytest.param(
+ [
+ {"role": "user", "content": "Hi"},
+ {"role": "assistant", "content": "Hello"},
+ {"role": 123, "content": "Go on"},
+ {"role": "system", "content": "Answer in one sentence."},
+ ],
+ "messages[2].role",
+ "int",
+ id="int_role_before_a_later_system_message",
+ ),
+ pytest.param(
+ [*_orphan_answer_with({"id": "toolu_01"})[:3], {"role": True, "content": "Go on"}],
+ "messages[3].role",
+ "bool",
+ id="bool_role_after_an_orphan_object_tool_call_id",
+ ),
+ pytest.param(
+ _search_answered_with({"id": "srvtoolu_01Search"}),
+ "messages[2].tool_call_id",
+ "dict",
+ id="object_tool_call_id_after_a_kept_server_result",
+ ),
+ pytest.param(
+ _search_answered_with(["srvtoolu_01Search"]),
+ "messages[2].tool_call_id",
+ "list",
+ id="list_tool_call_id_after_a_kept_server_result",
+ ),
+ pytest.param(
+ [_ASK, _WEATHER_CALL, {"role": "tool", "tool_call_id": {"id": "toolu_01"}, "content": "sunny"}],
+ "messages[2].tool_call_id",
+ "dict",
+ id="object_tool_call_id_answering_a_client_tool_call",
+ ),
+ pytest.param(
+ [
+ _ASK,
+ _WEATHER_CALL,
+ {"role": "function", "name": "get_weather", "tool_call_id": {"id": "toolu_01"}, "content": "sunny"},
+ ],
+ "messages[2].tool_call_id",
+ "dict",
+ id="object_tool_call_id_on_a_function_message",
+ ),
+ pytest.param(
+ [
+ _ASK,
+ {**_WEATHER_CALL, "tool_calls": [{**_WEATHER_CALL["tool_calls"][0], "id": {"value": "toolu_01"}}]},
+ {"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"},
+ ],
+ "messages[1].tool_calls[0].id",
+ "dict",
+ id="object_assistant_tool_call_id",
+ ),
+ pytest.param(
+ [
+ _ASK,
+ {**_WEATHER_CALL, "tool_calls": [{**_WEATHER_CALL["tool_calls"][0], "id": 7}]},
+ {"role": "tool", "tool_call_id": "7", "content": "sunny"},
+ ],
+ "messages[1].tool_calls[0].id",
+ "int",
+ id="int_assistant_tool_call_id",
+ ),
+ pytest.param(
+ [*_orphan_answer_with({"id": "toolu_01"})[:3], {"role": ["user"], "content": "Go on"}],
+ "messages[3].role",
+ "list",
+ id="list_role_after_an_orphan_object_tool_call_id",
+ ),
+ pytest.param(
+ [
+ *_orphan_answer_with({"id": "toolu_01"})[:3],
+ {**_WEATHER_CALL, "tool_calls": [{**_WEATHER_CALL["tool_calls"][0], "id": {"value": "toolu_01"}}]},
+ {"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"},
+ ],
+ "messages[3].tool_calls[0].id",
+ "dict",
+ id="object_assistant_tool_call_id_after_an_orphan_object_tool_call_id",
+ ),
+ pytest.param(
+ [
+ {**_ASK, "tool_call_id": {"id": "stray"}},
+ _WEATHER_CALL,
+ {"role": "tool", "tool_call_id": {"id": "toolu_01"}, "content": "sunny"},
+ ],
+ "messages[2].tool_call_id",
+ "dict",
+ id="object_tool_call_id_after_a_stray_id_on_a_user_message",
+ ),
+ pytest.param(
+ [
+ {**_ASK, "tool_calls": [{**_WEATHER_CALL["tool_calls"][0], "id": {"value": "stray"}}]},
+ {**_WEATHER_CALL, "tool_calls": [{**_WEATHER_CALL["tool_calls"][0], "id": {"value": "toolu_01"}}]},
+ {"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"},
+ ],
+ "messages[1].tool_calls[0].id",
+ "dict",
+ id="object_assistant_tool_call_id_after_stray_tool_calls_on_a_user_message",
+ ),
+ pytest.param(
+ [_ASK, {"role": [], "content": "ok"}, {"role": "system", "content": "Be terse."}],
+ "messages[1].role",
+ "list",
+ id="empty_list_role_before_a_system_message",
+ ),
+ pytest.param(
+ [_ASK, {"role": {}, "content": "ok"}, {"role": "user", "content": "again"}],
+ "messages[1].role",
+ "dict",
+ id="empty_object_role",
+ ),
+ ],
+)
+@pytest.mark.parametrize("modify_params", [False, True])
+@pytest.mark.parametrize("model", [UNFLAGGED_CLAUDE, FLAGGED_CLAUDE])
+def test_chat_transform_request_answers_400_naming_a_non_string_role_or_tool_call_id(
+ local_model_cost_map,
+ monkeypatch: pytest.MonkeyPatch,
+ messages: list[dict[str, object]],
+ field_path: str,
+ type_name: str,
+ modify_params: bool,
+ model: str,
+):
+ monkeypatch.setattr(litellm, "modify_params", modify_params)
+
+ with pytest.raises(AnthropicError) as raised:
+ _chat_request(AnthropicConfig(), model, copy.deepcopy(messages))
+
+ assert (raised.value.status_code, raised.value.message) == (
+ 400,
+ f"Invalid type for '{field_path}': expected a string, got {type_name}",
+ )
+
+
+@pytest.mark.parametrize(("role", "type_name"), [(["user"], "list"), ({"name": "user"}, "dict"), ([], "list")])
+def test_chat_transform_request_names_a_non_string_role_on_a_cache_marked_message_under_drop_params(
+ local_model_cost_map, role: object, type_name: str
+):
+ marked: Final = {"role": role, "content": [{"type": "text", "text": "ok"}], "cache_control": {"type": "ephemeral"}}
+
+ with pytest.raises(AnthropicError) as raised:
+ AnthropicConfig().transform_request(
+ model=UNFLAGGED_CLAUDE,
+ messages=[_ASK, marked],
+ optional_params={},
+ litellm_params={"drop_params": True},
+ headers={},
+ )
+
+ assert (raised.value.status_code, raised.value.message) == (
+ 400,
+ f"Invalid type for 'messages[1].role': expected a string, got {type_name}",
+ )
+
+
+@pytest.mark.parametrize(
+ ("malformed", "well_formed", "modify_params"),
+ [
+ pytest.param(
+ [{**_ASK, "tool_call_id": {"id": "stray"}}], [_ASK], False, id="stray_tool_call_id_on_a_user_message"
+ ),
+ pytest.param(
+ [{**_ASK, "tool_calls": [{**_WEATHER_CALL["tool_calls"][0], "id": {"value": "toolu_01"}}]}],
+ [_ASK],
+ False,
+ id="stray_tool_calls_on_a_user_message",
+ ),
+ pytest.param(
+ _orphan_answer_with({"id": "toolu_01"}),
+ _orphan_answer_with("toolu_01"),
+ True,
+ id="orphan_answer_dropped_under_modify_params",
+ ),
+ ],
+)
+def test_chat_transform_request_keeps_converting_a_non_string_id_it_never_reads(
+ local_model_cost_map,
+ monkeypatch: pytest.MonkeyPatch,
+ malformed: list[dict[str, object]],
+ well_formed: list[dict[str, object]],
+ modify_params: bool,
+):
+ monkeypatch.setattr(litellm, "modify_params", modify_params)
+
+ converted: Final = _chat_request(AnthropicConfig(), UNFLAGGED_CLAUDE, copy.deepcopy(malformed))
+
+ assert (
+ converted["messages"]
+ == _chat_request(AnthropicConfig(), UNFLAGGED_CLAUDE, copy.deepcopy(well_formed))["messages"]
+ )
+
+
+def test_chat_transform_request_keeps_a_conversion_error_that_a_dropped_orphan_answer_did_not_cause(
+ local_model_cost_map, monkeypatch: pytest.MonkeyPatch
+):
+ monkeypatch.setattr(litellm, "modify_params", True)
+
+ with pytest.raises(AnthropicError) as raised:
+ _chat_request(
+ AnthropicConfig(),
+ UNFLAGGED_CLAUDE,
+ [*_orphan_answer_with({"id": "toolu_01"})[:3], {"role": "bogus", "content": "Go on"}],
+ )
+
+ assert raised.value.status_code == 400
+ assert "Invalid Message passed in {'role': 'bogus'" in raised.value.message
+
+
+@pytest.mark.parametrize(
+ ("later_turn", "real_cause"),
+ [
+ pytest.param(
+ [{"role": "user", "content": ["hi", "there"]}],
+ "'str' object has no attribute 'get'",
+ id="string_content_parts",
+ ),
+ pytest.param(
+ [
+ {
+ **_WEATHER_CALL,
+ "tool_calls": [
+ {
+ **_WEATHER_CALL["tool_calls"][0],
+ "function": {"name": "get_weather", "arguments": {"city": "Paris"}},
+ }
+ ],
+ },
+ {"role": "tool", "tool_call_id": "toolu_01", "content": "rain"},
+ ],
+ "'dict' object has no attribute 'strip'",
+ id="object_tool_call_arguments",
+ ),
+ pytest.param(
+ [{"role": "user", "content": [{"type": "image_url", "image_url": ["x"]}]}],
+ "list indices must be integers or slices, not str",
+ id="list_image_url",
+ ),
+ ],
+)
+@pytest.mark.parametrize("dropped_answer_id", [7, {"id": "toolu_01"}], ids=["int", "object"])
+def test_chat_transform_request_never_blames_a_dropped_tool_answer_id_for_another_failure(
+ local_model_cost_map,
+ monkeypatch: pytest.MonkeyPatch,
+ later_turn: list[dict[str, object]],
+ real_cause: str,
+ dropped_answer_id: object,
+):
+ monkeypatch.setattr(litellm, "modify_params", True)
+
+ with pytest.raises(AnthropicError) as raised:
+ _chat_request(AnthropicConfig(), UNFLAGGED_CLAUDE, [*_orphan_answer_with(dropped_answer_id)[:3], *later_turn])
+
+ assert raised.value.status_code == 400
+ assert raised.value.message.startswith(real_cause)
+
+
+def test_chat_transform_request_never_blames_a_numeric_assistant_tool_call_id_for_an_earlier_failure(
+ local_model_cost_map,
+):
+ with pytest.raises(AnthropicError) as raised:
+ _chat_request(
+ AnthropicConfig(),
+ UNFLAGGED_CLAUDE,
+ [
+ {"role": "user", "content": ["hi", "there"]},
+ {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [{"id": 5, "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}],
+ },
+ {"role": "tool", "tool_call_id": "5", "content": "rain"},
+ ],
+ )
+
+ assert raised.value.status_code == 400
+ assert raised.value.message.startswith("'str' object has no attribute 'get'")
+
+
+@pytest.mark.parametrize("empty_role", [0, False])
+def test_chat_transform_request_never_blames_an_empty_role_for_another_failure(local_model_cost_map, empty_role):
+ list_part_type_after_an_empty_role: Final = [
+ _ASK,
+ {"role": empty_role, "content": "ok"},
+ {"role": "user", "content": [{"type": ["text"], "text": "again"}]},
+ {"role": "system", "content": "Be terse."},
+ ]
+
+ with pytest.raises(TypeError):
+ _chat_request(AnthropicConfig(), FLAGGED_CLAUDE, list_part_type_after_an_empty_role)
+
+
+def test_chat_transform_request_names_no_field_for_a_failure_outside_roles_and_tool_call_ids(local_model_cost_map):
+ list_part_type: Final = [
+ _ASK,
+ {"role": "assistant", "content": "ok"},
+ {"role": "user", "content": [{"type": ["text"], "text": "again"}]},
+ {"role": "system", "content": "Be terse."},
+ ]
+
+ with pytest.raises(TypeError):
+ _chat_request(AnthropicConfig(), FLAGGED_CLAUDE, list_part_type)
+
+
def _five_cache_hint_conversation() -> list[dict[str, object]]:
return [
{"role": "system", "content": [{"type": "text", "text": "system prompt", "cache_control": {"type": "ephemeral"}}]},
diff --git a/tests/unit/llms/anthropic/messages/context_management/test_clear_tool_uses.py b/tests/unit/llms/anthropic/messages/context_management/test_clear_tool_uses.py
index 4cbf4cfa5f6..70eddd9e16e 100644
--- a/tests/unit/llms/anthropic/messages/context_management/test_clear_tool_uses.py
+++ b/tests/unit/llms/anthropic/messages/context_management/test_clear_tool_uses.py
@@ -614,9 +614,18 @@ def test_clear_tool_inputs_list_clears_only_the_named_tools():
}
+def _history_with_first_block_field(index: int, field: str, value: object) -> list[dict[str, object]]:
+ history: Final = _make_history(n_pairs=4)
+ return [
+ {**message, "content": [{**message["content"][0], field: value}, *message["content"][1:]]}
+ if position == index
+ else message
+ for position, message in enumerate(history)
+ ]
+
+
def test_a_tool_use_with_a_non_string_id_is_left_for_the_provider_to_reject():
- messages = _make_history(n_pairs=4)
- messages[1]["content"][0]["id"] = ["toolu_00"]
+ messages: Final = _history_with_first_block_field(1, "id", ["toolu_00"])
new_messages, applied = apply_clear_tool_uses_20250919(
model=MODEL,
messages=messages,
@@ -635,6 +644,24 @@ def test_a_tool_use_with_a_non_string_id_is_left_for_the_provider_to_reject():
assert (new_messages[3]["content"][0]["input"], new_messages[5]["content"][0]["input"]) == ({}, {})
+def test_a_tool_result_with_a_non_string_tool_use_id_is_left_for_the_provider_to_reject():
+ messages: Final = _history_with_first_block_field(2, "tool_use_id", ["toolu_00"])
+ new_messages, applied = apply_clear_tool_uses_20250919(
+ model=MODEL,
+ messages=messages,
+ tools=None,
+ system=None,
+ edit_spec={
+ "type": "clear_tool_uses_20250919",
+ "trigger": {"type": "tool_uses", "value": 0},
+ "keep": {"type": "tool_uses", "value": 1},
+ },
+ )
+ assert applied is not None
+ assert new_messages[2] == messages[2]
+ assert _cleared_ids(new_messages[3:]) == {"toolu_01", "toolu_02"}
+
+
async def _malformed_spec_error(edits: list[dict[str, object]]) -> AnthropicContextManagementError:
with pytest.raises(AnthropicContextManagementError) as raised:
await apply_context_management(
diff --git a/tests/unit/llms/bedrock/chat/agentcore/test_agentcore_transformation.py b/tests/unit/llms/bedrock/chat/agentcore/test_agentcore_transformation.py
index 413061a732d..acfd954215d 100644
--- a/tests/unit/llms/bedrock/chat/agentcore/test_agentcore_transformation.py
+++ b/tests/unit/llms/bedrock/chat/agentcore/test_agentcore_transformation.py
@@ -19,8 +19,9 @@ from typing import Final
from unittest.mock import MagicMock, Mock, patch
import litellm
+from litellm.exceptions import MidStreamFallbackError
from litellm.llms.bedrock.chat.agentcore.transformation import AmazonAgentCoreConfig
-from litellm.llms.custom_httpx.http_handler import HTTPHandler
+from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
class TestAgentCoreAcceptHeader:
@@ -1271,6 +1272,87 @@ def test_agentcore_runtime_response_becomes_assistant_message(
assert response.choices[0].finish_reason == "stop"
+_AGENTCORE_RUNTIME_URL: Final = (
+ "https://bedrock-agentcore.us-west-2.amazonaws.com/runtimes/"
+ "arn%3Aaws%3Abedrock-agentcore%3Aus-west-2%3A888602223428%3Aruntime%2Fhosted_agent_13sf6-cALnp38iZD/invocations"
+)
+_AGENTCORE_DELTAS: Final = (
+ 'data: {"event":{"messageStart":{"role":"assistant"}}}\n\n'
+ 'data: {"event":{"contentBlockDelta":{"delta":{"text":"Machine learning lets "},"contentBlockIndex":0}}}\n\n'
+ 'data: {"event":{"contentBlockDelta":{"delta":{"text":"computers learn from data."},"contentBlockIndex":0}}}\n\n'
+)
+
+
+async def _stream_agentcore(body: str, sync_mode: bool) -> list[tuple[str | None, str | None]]:
+ """Stream `body` from the runtime as SSE and return each chunk's (content, finish_reason)."""
+ kwargs: Final = {
+ "model": "bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/"
+ "hosted_agent_13sf6-cALnp38iZD",
+ "messages": [{"role": "user", "content": "Explain machine learning in simple terms"}],
+ "aws_access_key_id": "AKIAAGENTCOREKEY",
+ "aws_secret_access_key": "agentcore-secret",
+ "stream": True,
+ }
+ if sync_mode:
+ return [
+ (chunk.choices[0].delta.content, chunk.choices[0].finish_reason)
+ for chunk in litellm.completion(**kwargs, client=HTTPHandler())
+ ]
+ return [
+ (chunk.choices[0].delta.content, chunk.choices[0].finish_reason)
+ async for chunk in await litellm.acompletion(
+ **kwargs, client=AsyncHTTPHandler(transport=httpx.AsyncHTTPTransport())
+ )
+ ]
+
+
+@pytest.mark.parametrize(
+ "ending",
+ [
+ pytest.param(
+ 'data: {"event":{"contentBlockStop":{"contentBlockIndex":0}}}\n\n'
+ 'data: {"event":{"messageStop":{"stopReason":"end_turn"}}}\n\n',
+ id="message_stop",
+ ),
+ pytest.param(
+ 'data: {"event":{"messageStop":{"stopReason":"end_turn"}}}\n\n'
+ 'data: {"event":{"metadata":{"usage":{"inputTokens":9,"outputTokens":7,"totalTokens":16}}}}\n\n'
+ 'data: {"message":{"role":"assistant",'
+ '"content":[{"text":"Machine learning lets computers learn from data."}]}}\n\n',
+ id="strands_full",
+ ),
+ ],
+)
+@pytest.mark.parametrize("sync_mode", [True, False])
+@pytest.mark.respx(assert_all_called=True)
+async def test_agentcore_stream_that_signals_its_end_completes(
+ ending: str, sync_mode: bool, respx_mock: respx.Router
+) -> None:
+ respx_mock.post(_AGENTCORE_RUNTIME_URL).mock(
+ return_value=httpx.Response(
+ 200, content=(_AGENTCORE_DELTAS + ending).encode(), headers={"content-type": "text/event-stream"}
+ )
+ )
+
+ chunks: Final = await _stream_agentcore(_AGENTCORE_DELTAS + ending, sync_mode)
+
+ assert "".join(content for content, _ in chunks if content) == "Machine learning lets computers learn from data."
+ assert [finish for _, finish in chunks if finish] == ["stop"]
+
+
+@pytest.mark.parametrize("sync_mode", [True, False])
+@pytest.mark.respx(assert_all_called=True)
+async def test_agentcore_stream_cut_before_it_signals_its_end_raises(sync_mode: bool, respx_mock: respx.Router) -> None:
+ respx_mock.post(_AGENTCORE_RUNTIME_URL).mock(
+ return_value=httpx.Response(
+ 200, content=_AGENTCORE_DELTAS.encode(), headers={"content-type": "text/event-stream"}
+ )
+ )
+
+ with pytest.raises(MidStreamFallbackError, match="Stream ended before a finish_reason was received"):
+ await _stream_agentcore(_AGENTCORE_DELTAS, sync_mode)
+
+
def test_get_complete_url_sends_a_non_string_runtime_endpoint_to_that_endpoint():
vpc_endpoint: Final = "https://vpce-1.bedrock-agentcore.us-west-2.vpce.amazonaws.com"
diff --git a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py
index 9d9e39df944..512e1f325f4 100644
--- a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py
+++ b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py
@@ -4472,6 +4472,17 @@ def test_file_content_conversion_keeps_a_line_whose_json_holds_unicode_line_sepa
assert bedrock_record["modelInput"]["messages"] == [{"role": "user", "content": [{"text": text}]}]
+_CLAUDE_BODY_WITH_A_LIST_ROLE_BEFORE_A_SYSTEM_MESSAGE: Final = {
+ "model": "bedrock/us.anthropic.claude-haiku-5-5",
+ "max_tokens": 20,
+ "messages": [
+ {"role": "user", "content": "hi"},
+ {"role": ["user"], "content": "ok"},
+ {"role": "system", "content": "Be terse."},
+ ],
+}
+
+
def _batch_line(body: object) -> str:
return json.dumps({**_VALID_BATCH_RECORD, "body": body})
@@ -4516,6 +4527,11 @@ def _batch_line_with_body(field: str, value: object) -> str:
"Batch input file line 2 could not be converted to a Bedrock batch record",
id="string-messages",
),
+ pytest.param(
+ f"{_VALID_BATCH_LINE}\n{_batch_line(_CLAUDE_BODY_WITH_A_LIST_ROLE_BEFORE_A_SYSTEM_MESSAGE)}\n".encode(),
+ "Batch input file line 2 could not be converted to a Bedrock batch record",
+ id="claude-list-role-before-a-system-message",
+ ),
pytest.param(
f"{_VALID_BATCH_LINE}\n{'[' * 1_000_000}{']' * 1_000_000}\n".encode(),
"Batch input file line 2 could not be parsed",
diff --git a/tests/unit/llms/cohere/chat/test_cohere_transformation.py b/tests/unit/llms/cohere/chat/test_cohere_transformation.py
index 8d39c301029..d32b3bcbeca 100644
--- a/tests/unit/llms/cohere/chat/test_cohere_transformation.py
+++ b/tests/unit/llms/cohere/chat/test_cohere_transformation.py
@@ -1,3 +1,4 @@
+import itertools
import json
from collections.abc import Callable, Iterator
from typing import Final
@@ -653,9 +654,7 @@ async def test_cohere_v2_annotations(respx_mock: respx.MockRouter) -> None:
def test_cohere_v2_parameter_mapping(respx_mock: respx.MockRouter) -> None:
messages: Final = [{"role": "user", "content": "Generate a creative story."}]
route: Final = respx_mock.post(COHERE_V2_CHAT_URL).mock(
- return_value=httpx.Response(
- 200, json=_cohere_v2_response({"content": [{"type": "text", "text": "A story."}]})
- )
+ return_value=httpx.Response(200, json=_cohere_v2_response({"content": [{"type": "text", "text": "A story."}]}))
)
litellm.completion(
@@ -705,3 +704,123 @@ def test_cohere_v2_error_handling(respx_mock: respx.MockRouter) -> None:
assert error.value.status_code == 400
assert error.value.llm_provider == "cohere"
assert "invalid request: model 'invalid-model' not found" in error.value.message
+
+
+def _cohere_v2_tool_call_events(index: int, call_id: str, city: str) -> tuple[dict[str, object], ...]:
+ return (
+ {
+ "type": "tool-call-start",
+ "index": index,
+ "delta": {
+ "message": {
+ "tool_calls": {
+ "id": call_id,
+ "type": "function",
+ "function": {"name": "get_weather", "arguments": ""},
+ }
+ }
+ },
+ },
+ {
+ "type": "tool-call-delta",
+ "index": index,
+ "delta": {"message": {"tool_calls": {"function": {"arguments": '{"city": '}}}},
+ },
+ {
+ "type": "tool-call-delta",
+ "index": index,
+ "delta": {"message": {"tool_calls": {"function": {"arguments": json.dumps(city) + "}"}}}},
+ },
+ {"type": "tool-call-end", "index": index},
+ )
+
+
+@pytest.mark.usefixtures("_cohere_httpx_transport")
+@pytest.mark.respx(assert_all_called=True)
+async def test_cohere_v2_stream_returns_parallel_tool_calls(respx_mock: respx.MockRouter) -> None:
+ stream_events: Final = (
+ {"type": "message-start", "id": "c0a2b6e1", "delta": {"message": {"role": "assistant"}}},
+ *_cohere_v2_tool_call_events(0, "get_weather_paris", "Paris"),
+ *_cohere_v2_tool_call_events(1, "get_weather_tokyo", "Tokyo"),
+ {
+ "type": "message-end",
+ "delta": {"finish_reason": "TOOL_CALL", "usage": {"tokens": {"input_tokens": 40, "output_tokens": 30}}},
+ },
+ )
+ respx_mock.post(COHERE_V2_CHAT_URL).mock(
+ return_value=httpx.Response(200, content="".join(json.dumps(event) + "\n" for event in stream_events).encode())
+ )
+
+ response: Final = await litellm.acompletion(
+ model="cohere_chat/v2/command-a-plus-05-2026",
+ messages=[{"role": "user", "content": "What is the weather in Paris and in Tokyo?"}],
+ tools=[
+ {
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
+ },
+ }
+ ],
+ stream=True,
+ api_key="cohere-test-key",
+ )
+ chunks: Final = [chunk async for chunk in response]
+ rebuilt: Final = litellm.stream_chunk_builder(chunks)
+
+ assert rebuilt is not None
+ assert [
+ (call.id, call.function.name, json.loads(call.function.arguments))
+ for call in rebuilt.choices[0].message.tool_calls
+ ] == [
+ ("get_weather_paris", "get_weather", {"city": "Paris"}),
+ ("get_weather_tokyo", "get_weather", {"city": "Tokyo"}),
+ ]
+ assert chunks[-1].choices[0].finish_reason == "tool_calls"
+
+
+@pytest.mark.usefixtures("_cohere_httpx_transport")
+@pytest.mark.respx(assert_all_called=True)
+async def test_cohere_v2_stream_closes_argument_less_tool_call_with_empty_object(respx_mock: respx.MockRouter) -> None:
+ stream_events: Final = (
+ {"type": "message-start", "id": "5d1e9f20", "delta": {"message": {"role": "assistant"}}},
+ {
+ "type": "tool-call-start",
+ "index": 0,
+ "delta": {
+ "message": {
+ "tool_calls": {
+ "id": "get_server_time_0",
+ "type": "function",
+ "function": {"name": "get_server_time", "arguments": ""},
+ }
+ }
+ },
+ },
+ {"type": "tool-call-end", "index": 0},
+ {"type": "message-end", "delta": {"finish_reason": "TOOL_CALL"}},
+ )
+ respx_mock.post(COHERE_V2_CHAT_URL).mock(
+ return_value=httpx.Response(200, content="".join(json.dumps(event) + "\n" for event in stream_events).encode())
+ )
+
+ response: Final = await litellm.acompletion(
+ model="cohere_chat/v2/command-a-plus-05-2026",
+ messages=[{"role": "user", "content": "What time is it on the server?"}],
+ tools=[
+ {
+ "type": "function",
+ "function": {"name": "get_server_time", "parameters": {"type": "object", "properties": {}}},
+ }
+ ],
+ stream=True,
+ api_key="cohere-test-key",
+ )
+ deltas_per_chunk: Final = [chunk.choices[0].delta.tool_calls or () async for chunk in response]
+ streamed_calls: Final = tuple(itertools.chain.from_iterable(deltas_per_chunk))
+
+ assert [(call.id, call.function.name) for call in streamed_calls if call.id] == [
+ ("get_server_time_0", "get_server_time")
+ ]
+ assert json.loads("".join(call.function.arguments for call in streamed_calls)) == {}
diff --git a/tests/unit/llms/deepgram/test_deepgram_common_utils.py b/tests/unit/llms/deepgram/test_deepgram_common_utils.py
index 530888b70c4..4c1b4603076 100644
--- a/tests/unit/llms/deepgram/test_deepgram_common_utils.py
+++ b/tests/unit/llms/deepgram/test_deepgram_common_utils.py
@@ -7,6 +7,7 @@ import pytest
import litellm
from litellm.llms.deepgram.common_utils import (
+ DeepgramSpeakMeter,
deepgram_listen_addon_pricing_models,
deepgram_listen_audio_seconds,
deepgram_listen_callback_params,
@@ -18,7 +19,12 @@ from litellm.llms.deepgram.common_utils import (
deepgram_listen_requested_model,
deepgram_listen_transcript,
deepgram_listen_websocket_target,
+ deepgram_speak_character_rate,
+ deepgram_speak_registry_key,
+ deepgram_speak_requested_model,
+ deepgram_speak_websocket_target,
)
+from litellm.types.passthrough_endpoints.pass_through_endpoints import WebSocketSessionUsage
NOVA_3_URL: Final = "wss://api.deepgram.com/v1/listen?model=nova-3&encoding=linear16&sample_rate=16000"
@@ -324,3 +330,102 @@ def test_deepgram_listen_is_priced(
)
def test_deepgram_listen_addon_pricing_models(upstream_url: str, expected: tuple[str, ...]):
assert deepgram_listen_addon_pricing_models(upstream_url) == expected
+
+
+@pytest.mark.parametrize(
+ ("api_base", "query_string", "expected"),
+ [
+ pytest.param(
+ None,
+ "encoding=linear16",
+ "wss://api.deepgram.com/v1/speak?encoding=linear16&model=aura-asteria-en",
+ id="no model: Deepgram's own default, named so billing and upstream agree",
+ ),
+ pytest.param(
+ None,
+ "model=aura-asteria-en&model=aura-2-thalia-en",
+ "wss://api.deepgram.com/v1/speak?model=aura-asteria-en",
+ id="second model dropped: Deepgram honours the last, auth and pricing read the first",
+ ),
+ pytest.param(
+ "http://localhost:8080/v1",
+ "model=aura-2-thalia-en",
+ "ws://localhost:8080/v1/speak?model=aura-2-thalia-en",
+ id="server-configured api base",
+ ),
+ ],
+)
+def test_deepgram_speak_websocket_target(api_base: str | None, query_string: str, expected: str):
+ assert deepgram_speak_websocket_target(api_base, query_string) == expected
+
+
+@pytest.mark.parametrize(
+ "query_string",
+ [
+ "",
+ "model=",
+ "model=&model=aura-2-thalia-en",
+ "model=aura-2-thalia-en",
+ "model=aura-asteria-en&model=aura-2-thalia-en",
+ "encoding=mulaw",
+ ],
+)
+def test_deepgram_speak_requested_voice_is_the_only_voice_the_upstream_target_carries(query_string: str):
+ """Authorization runs against the requested voice and the upstream URL is built separately, so they must agree or
+ a key could be authorized for one voice and reach another"""
+ target: Final = deepgram_speak_websocket_target(None, query_string)
+ assert parse_qs(urlparse(target).query)["model"] == [deepgram_speak_requested_model(query_string)]
+
+
+@pytest.mark.usefixtures("local_model_cost_map")
+@pytest.mark.parametrize(
+ ("model", "registry_key", "priced"),
+ [
+ ("aura-asteria-en", "deepgram/aura", True),
+ ("aura-2-thalia-en", "deepgram/aura-2", True),
+ ("aura-2-celeste-es", "deepgram/aura-2", True),
+ ("aura-99-x-en", "deepgram/aura-99", False),
+ ("aura-2", "deepgram/aura-2", True),
+ ("nova-3", "deepgram/nova-3", False),
+ ],
+)
+def test_deepgram_speak_prices_each_voice_at_its_generation_row_only(model: str, registry_key: str, priced: bool):
+ """A voice is never billed at another generation's rate, and a listen model's row is not a speak price"""
+ upstream_url: Final = f"wss://api.deepgram.com/v1/speak?model={model}"
+
+ assert deepgram_speak_registry_key(upstream_url) == registry_key
+ assert deepgram_speak_character_rate(upstream_url) == (
+ litellm.model_cost[registry_key]["input_cost_per_character"] if priced else None
+ )
+
+
+@pytest.mark.parametrize(
+ ("frame", "characters"),
+ [
+ ('{"type": "Speak", "text": "Hello world."}', 12),
+ ('{"type":"Speak","text":"¿qué tal?"}', 9),
+ ('{"type": "Flush"}', 0),
+ ('{"type": "Speak"}', 0),
+ ('{"type": "Speak", "text": 12}', 0),
+ ('["Speak", "Hello"]', 0),
+ ("Hello", 0),
+ ("[" * 100_000, 0),
+ ],
+)
+def test_deepgram_speak_meter_measures_only_speak_text_and_never_raises(frame: str, characters: int):
+ meter: Final = DeepgramSpeakMeter("wss://api.deepgram.com/v1/speak?model=aura-2-thalia-en", character_rate=1.0)
+
+ assert meter.measure(frame) == characters
+
+
+@pytest.mark.parametrize(
+ ("upstream_url", "model"),
+ [
+ ("wss://api.deepgram.com/v1/speak?model=aura-2-thalia-en", "aura-2-thalia-en"),
+ ("wss://api.deepgram.com/v1/speak?encoding=linear16&model=aura-asteria-en", "aura-asteria-en"),
+ ],
+)
+def test_deepgram_speak_meter_prices_the_total_at_the_rate_it_holds(upstream_url: str, model: str) -> None:
+ assert DeepgramSpeakMeter(upstream_url, character_rate=0.25).price(47) == WebSocketSessionUsage(
+ model=model, custom_llm_provider="deepgram", response_cost=47 * 0.25
+ )
diff --git a/tests/unit/llms/langgraph/chat/test_langgraph_transformation.py b/tests/unit/llms/langgraph/chat/test_langgraph_transformation.py
index 238f7dd2f26..256876b7eea 100644
--- a/tests/unit/llms/langgraph/chat/test_langgraph_transformation.py
+++ b/tests/unit/llms/langgraph/chat/test_langgraph_transformation.py
@@ -22,12 +22,14 @@ import asyncio
import importlib
import json
import os
+from collections.abc import AsyncIterator
+from typing import Final
import httpx
import pytest
import litellm
-from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
+from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_async_httpx_client
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
@@ -268,3 +270,28 @@ async def test_langgraph_streaming_acompletion_without_a_client_posts_through_th
"assistant_id": "agent",
"input": {"messages": [{"role": "human", "content": "What is 25 * 4?"}]},
}
+
+
+@pytest.mark.asyncio
+async def test_langgraph_async_stream_delivers_every_step_of_the_run_before_its_stop() -> None:
+ async def events() -> AsyncIterator[bytes]:
+ question: Final = {"type": "human", "content": "What is 25 * 4?"}
+ answers: Final = ({"type": "ai", "content": "25 * 4"}, {"type": "ai", "content": " is 100"})
+ yield b'event: metadata\ndata: {"run_id": "run-1", "attempt": 1}\n\n'
+ for step in range(len(answers) + 1):
+ yield b"event: values\ndata: " + json.dumps({"messages": [question, *answers[:step]]}).encode() + b"\n\n"
+
+ client: Final = AsyncHTTPHandler(
+ transport=httpx.MockTransport(lambda request: httpx.Response(200, content=events()))
+ )
+
+ response: Final = await litellm.acompletion(
+ model="langgraph/agent",
+ messages=[{"role": "user", "content": "What is 25 * 4?"}],
+ api_base="http://langgraph.test",
+ stream=True,
+ client=client,
+ )
+ streamed: Final = [(chunk.choices[0].delta.content, chunk.choices[0].finish_reason) async for chunk in response]
+
+ assert streamed == [("25 * 4", None), (" is 100", None), (None, "stop")]
diff --git a/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py
index 3eb5f544ac6..d80a5e835c9 100644
--- a/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py
+++ b/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py
@@ -29,6 +29,7 @@ from openai.types.responses import (
ResponseOutputRefusal,
ResponseOutputText,
)
+from openai.types.responses.response_compaction_item import ResponseCompactionItem
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@@ -3349,6 +3350,25 @@ class TestGetStructuredMessages:
result = handler.get_structured_messages(data)
assert result is None
+ def test_should_return_none_for_a_moderation_style_list_of_strings(self):
+ handler = OpenAIResponsesHandler()
+ data = {"model": "omni-moderation-latest", "input": ["safe text", "more safe text"]}
+ assert handler.get_structured_messages(data) is None
+
+ def test_should_keep_an_image_item_replayed_with_an_sdk_compaction_item(self):
+ handler = OpenAIResponsesHandler()
+ image_url = "https://example.com/cat.png"
+ data = {
+ "model": "gpt-5.5",
+ "input": [
+ ResponseCompactionItem(type="compaction", id="cmp_1", encrypted_content="abc"),
+ {"role": "user", "content": [{"type": "input_image", "image_url": image_url}]},
+ ],
+ }
+ result = handler.get_structured_messages(data)
+ assert result is not None
+ assert image_url in json.dumps(result)
+
class ToolAppendingGuardrail(CustomGuardrail):
"""Guardrail that appends a new function tool, mimicking a guardrail that
diff --git a/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py b/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py
index d6d6774d414..016a5151e16 100644
--- a/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py
+++ b/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py
@@ -18,6 +18,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import respx
+from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
import litellm
@@ -2941,3 +2942,56 @@ async def test_failed_upstream_stream_relays_its_body_only_when_that_is_a_json_o
"id": "req-1",
"method": "tasks/resubscribe",
}
+
+
+def _a2a_app_refusing_with(refusal: Exception) -> TestClient:
+ from fastapi import FastAPI
+
+ from litellm.proxy.agent_endpoints.a2a_endpoints import router
+ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+
+ def refuse() -> UserAPIKeyAuth:
+ raise refusal
+
+ app: Final = FastAPI()
+ app.include_router(router)
+ app.dependency_overrides[user_api_key_auth] = refuse
+ return TestClient(app)
+
+
+@pytest.mark.parametrize("path", ["/a2a/agent-1", "/a2a/agent-1/message/send", "/v1/a2a/agent-1/message/send"])
+def test_a2a_auth_refusals_answer_as_jsonrpc_errors_with_the_callers_id(path: str):
+ from litellm.proxy._types import ProxyException
+
+ client: Final = _a2a_app_refusing_with(
+ ProxyException(message="Budget has been exceeded!", type="budget_exceeded", param=None, code=422)
+ )
+
+ response: Final = client.post(path, json={"jsonrpc": "2.0", "id": "req-7", "method": "message/send"})
+
+ assert response.status_code == 422
+ assert response.json() == {
+ "jsonrpc": "2.0",
+ "id": "req-7",
+ "error": {
+ "code": -32000,
+ "message": "Budget has been exceeded!",
+ "data": {"type": "budget_exceeded", "param": None, "code": "422"},
+ },
+ }
+
+
+def test_a2a_http_refusals_answer_as_jsonrpc_errors_with_a_null_id_for_unparseable_bodies():
+ from fastapi import HTTPException
+
+ client: Final = _a2a_app_refusing_with(HTTPException(status_code=403, detail="Agent 'agent-1' is not allowed"))
+
+ response: Final = client.post("/a2a/agent-1", content=b"not json")
+
+ assert response.status_code == 403
+ body: Final = response.json()
+ assert body["jsonrpc"] == "2.0"
+ assert body["id"] is None
+ assert body["error"]["code"] == -32000
+ assert body["error"]["message"] == "Agent 'agent-1' is not allowed"
+ assert body["error"]["data"]["code"] == "403"
diff --git a/tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py b/tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py
index dc7bd8cb312..8e9e87f370a 100644
--- a/tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py
+++ b/tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py
@@ -11202,9 +11202,47 @@ def test_route_skips_budget_checks_marks_only_spend_free_routes() -> None:
assert route_skips_budget_checks(route="/v1/chat/completions") is False
-def test_request_skips_budget_checks_extends_route_rule_with_zero_cost_models() -> None:
- assert request_skips_budget_checks(route="/v1/models", model=None, llm_router=None) is True
- assert request_skips_budget_checks(route="/v1/chat/completions", model=None, llm_router=None) is False
+@pytest.mark.parametrize(
+ "route, request_body, model, expected",
+ [
+ ("/v1/models", None, None, True),
+ ("/v1/models", None, "paid", True),
+ ("/v1/chat/completions", None, None, False),
+ ("/v1/chat/completions", None, "free", True),
+ ("/v1/chat/completions", None, "paid", False),
+ ("/mcp", {"method": "tools/list"}, "paid", True),
+ ("/mcp", {"method": "tools/call"}, "paid", False),
+ ("/mcp", None, "paid", False),
+ ("/mcp/tools/call", {"method": "initialize"}, "paid", False),
+ ("/health/services", None, "paid", False),
+ ],
+)
+def test_request_skips_budget_checks_for_non_spending_requests(
+ route: str, request_body: Mapping[str, object] | None, model: str | None, expected: bool
+) -> None:
+ router: Final = litellm.Router(
+ model_list=[
+ {
+ "model_name": "free",
+ "litellm_params": {
+ "model": "openai/gpt-4o-mini",
+ "input_cost_per_token": 0,
+ "output_cost_per_token": 0,
+ },
+ },
+ {
+ "model_name": "paid",
+ "litellm_params": {
+ "model": "openai/gpt-4o-mini",
+ "input_cost_per_token": 1,
+ "output_cost_per_token": 1,
+ },
+ },
+ ]
+ )
+ assert (
+ request_skips_budget_checks(route=route, model=model, llm_router=router, request_body=request_body) is expected
+ )
def _agent_model_ceiling_resolver(
diff --git a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py
index b7f3ded9291..10738eabddf 100644
--- a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py
+++ b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py
@@ -5,7 +5,7 @@ import os
import subprocess
import sys
import time
-from collections.abc import Awaitable, Callable, Mapping, Sequence
+from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
from contextlib import contextmanager, nullcontext
from datetime import datetime, timedelta, timezone
from functools import partial
@@ -17,6 +17,7 @@ from typing import Final
from unittest.mock import ANY, AsyncMock, MagicMock, patch
+import fastapi
import orjson
import pytest
from fastapi import HTTPException, Request, status
@@ -24,6 +25,8 @@ from starlette.datastructures import UploadFile
import litellm
import litellm.proxy.proxy_server
+from litellm.proxy import utils as proxy_utils
+from litellm.proxy.common_utils import user_api_key_cache as key_cache_utils
from litellm.caching.dual_cache import DualCache
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
from litellm.proxy.common_utils.user_api_key_cache import (
@@ -85,6 +88,7 @@ from litellm.proxy.auth.user_api_key_auth import (
user_api_key_auth,
user_api_key_auth_websocket_for_model,
)
+from litellm.proxy.pass_through_endpoints import pass_through_endpoints as pass_through
from litellm.proxy.spend_tracking.carried_budget_state import carried_budget_metadata
from tests.unit.proxy.db.fake_prisma_engine import engine_call
from tests._master_key import MASTER_KEY
@@ -11173,6 +11177,30 @@ async def test_temp_budget_increase_applied_for_cached_key():
assert cached_after.max_budget == 2.0
+BUDGETED_PASS_THROUGH_ROUTE: Final = "/custom-provider/infer"
+
+
+@pytest.fixture
+def registered_budgeted_route() -> Iterator[None]:
+ pass_through.InitPassThroughEndpointHelpers.add_exact_path_route(
+ app=fastapi.FastAPI(),
+ path=BUDGETED_PASS_THROUGH_ROUTE,
+ target="https://provider.example/infer",
+ custom_headers=None,
+ forward_headers=None,
+ merge_query_params=None,
+ dependencies=None,
+ cost_per_request=1.0,
+ endpoint_id="budget-regression",
+ auth=True,
+ methods=["POST"],
+ )
+ try:
+ yield
+ finally:
+ pass_through.InitPassThroughEndpointHelpers.remove_endpoint_routes(endpoint_id="budget-regression")
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize(
"team_member_spend, expect_blocked",
@@ -11182,7 +11210,11 @@ async def test_temp_budget_increase_applied_for_cached_key():
(2.39, False),
],
)
-async def test_cached_key_team_member_budget_blocks_at_exact_cap(team_member_spend, expect_blocked):
+@pytest.mark.usefixtures("registered_budgeted_route")
+@pytest.mark.parametrize(
+ "route", ["/v1/messages", "/health/services", "/health/test_connection", BUDGETED_PASS_THROUGH_ROUTE]
+)
+async def test_cached_key_team_member_budget_blocks_at_exact_cap(team_member_spend, expect_blocked, route):
"""A team member counter sitting exactly at the cap (where a resized reservation
lands it) must be rejected by the cached-key auth path like every other budget check."""
from litellm.proxy._types import LiteLLM_TeamMembership, LiteLLM_TeamTableCachedObj
@@ -11227,8 +11259,8 @@ async def test_cached_key_team_member_budget_blocks_at_exact_cap(team_member_spe
)
mock_request = MagicMock()
- mock_request.url.path = "/v1/messages"
- mock_request.method = "POST"
+ mock_request.url.path = route
+ mock_request.method = "POST" if route == "/v1/messages" else "GET"
mock_request.headers = {"authorization": f"Bearer {api_key}"}
mock_request.query_params = {}
mock_request.state = SimpleNamespace()
@@ -11276,6 +11308,107 @@ async def test_cached_key_team_member_budget_blocks_at_exact_cap(team_member_spe
assert f"TeamMember={user_id}:{team_id}" in exc_info.value.message
+@pytest.mark.asyncio
+@pytest.mark.parametrize("key_max_budget, member_max_budget", [(None, 1.0), (1.0, None)])
+@pytest.mark.parametrize(
+ "route, expect_blocked",
+ [
+ ("/models", False),
+ ("/v1/models", False),
+ ("/utils/model_info", False),
+ ("/model/info", False),
+ ("/key/info", False),
+ ("/user/info", False),
+ ("/v1/chat/completions", True),
+ ("/v1/messages", True),
+ ],
+)
+async def test_cached_key_budget_preserves_non_spending_access(
+ key_max_budget: float | None,
+ member_max_budget: float | None,
+ route: str,
+ expect_blocked: bool,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ api_key: Final = "sk-budget-route-fixture"
+ hashed_token: Final = proxy_utils.hash_token(api_key)
+ team_id: Final = "budget-route-team"
+ user_id: Final = "budget-route-user"
+ cache: Final = DualCache()
+ await cache_key_object(
+ hashed_token=hashed_token,
+ user_api_key_obj=UserAPIKeyAuth(
+ token=hashed_token,
+ user_id=user_id,
+ team_id=team_id,
+ team_member_spend=2.0,
+ spend=2.0,
+ max_budget=key_max_budget,
+ ),
+ user_api_key_cache=cache,
+ proxy_logging_obj=None,
+ )
+ await cache.async_set_cache(key=f"team_id:{team_id}", value=LiteLLM_TeamTableCachedObj(team_id=team_id))
+ await cache.async_set_cache(
+ key=user_id,
+ value=LiteLLM_UserTable(user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER),
+ )
+ await cache.async_set_cache(
+ key=key_cache_utils.team_membership_auth_cache_key(team_id=team_id, user_id=user_id),
+ value=LiteLLM_TeamMembership(
+ user_id=user_id,
+ team_id=team_id,
+ spend=2.0,
+ litellm_budget_table=LiteLLM_BudgetTable(max_budget=member_max_budget),
+ ),
+ )
+ await cache.async_set_cache(key=f"spend:team_member:{user_id}:{team_id}", value=2.0)
+ await cache.async_set_cache(key=f"spend:key:{hashed_token}", value=2.0)
+ request: Final = Request(
+ scope={
+ "type": "http",
+ "path": route,
+ "method": ("POST" if route in ("/v1/chat/completions", "/v1/messages") else "GET"),
+ "headers": [(b"authorization", f"Bearer {api_key}".encode())],
+ "query_string": b"",
+ }
+ )
+ proxy_logging: Final = MagicMock()
+ proxy_logging.budget_alerts = AsyncMock()
+ proxy_logging.post_call_failure_hook = AsyncMock(return_value=None)
+ monkeypatch.setattr(
+ litellm.proxy.proxy_server,
+ "general_settings",
+ {"disable_budget_reservation": True},
+ )
+ monkeypatch.setattr(litellm.proxy.proxy_server, "master_key", MASTER_KEY)
+ monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", MagicMock())
+ monkeypatch.setattr(litellm.proxy.proxy_server, "user_api_key_cache", cache)
+ monkeypatch.setattr(litellm.proxy.proxy_server, "spend_counter_cache", cache)
+ monkeypatch.setattr(litellm.proxy.proxy_server, "proxy_logging_obj", proxy_logging)
+
+ async def authenticate() -> UserAPIKeyAuth:
+ return await user_api_key_auth_builder(
+ request=request,
+ api_key=f"Bearer {api_key}",
+ azure_api_key_header="",
+ anthropic_api_key_header=None,
+ google_ai_studio_api_key_header=None,
+ azure_apim_header=None,
+ request_data={},
+ )
+
+ if expect_blocked:
+ with pytest.raises(ProxyException) as exc_info:
+ await authenticate()
+ assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
+ return
+
+ result: Final = await authenticate()
+ assert result.user_id == user_id
+ assert result.team_id == team_id
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize(
"expiry_offset, expect_blocked",
diff --git a/tests/unit/proxy/pass_through_endpoints/test_deepgram_ws_passthrough_routes.py b/tests/unit/proxy/pass_through_endpoints/test_deepgram_ws_passthrough_routes.py
index 404ed0f5800..b21a5e6c337 100644
--- a/tests/unit/proxy/pass_through_endpoints/test_deepgram_ws_passthrough_routes.py
+++ b/tests/unit/proxy/pass_through_endpoints/test_deepgram_ws_passthrough_routes.py
@@ -1,4 +1,5 @@
-"""Deepgram ``/v1/listen`` passthrough WebSocket route: registration, auth, credential injection, target URL."""
+"""Deepgram ``/v1/listen`` and ``/v1/speak`` passthrough WebSocket routes: registration, auth, credential injection,
+target URL."""
import asyncio
from collections.abc import Mapping
@@ -15,21 +16,27 @@ from starlette.websockets import WebSocketDisconnect
import litellm
from litellm.caching.dual_cache import DualCache
+from litellm.constants import WEBSOCKET_CLOSE_REASON_MAX_BYTES
+from litellm.litellm_core_utils.get_model_cost_map import adopt_model_cost_map
+from litellm.llms.deepgram.common_utils import DeepgramSpeakMeter
from litellm.proxy._lazy_features import LAZY_FEATURES
from litellm.proxy._types import LiteLLMRoutes, UserAPIKeyAuth
from litellm.proxy.auth.auth_checks import cache_key_object
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
_websocket_relay,
deepgram_listen_websocket_route,
+ deepgram_speak_websocket_route,
router,
)
from litellm.proxy.utils import hash_token
+from litellm.types.passthrough_endpoints.pass_through_endpoints import WebSocketSessionMeter
GET_CREDENTIALS: Final = (
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials"
)
USER_API_KEY_AUTH: Final = "litellm.proxy.auth.user_api_key_auth.user_api_key_auth"
LISTEN_PATHS: Final = ("/deepgram/v1/listen", "/deepgram/listen")
+SPEAK_PATHS: Final = ("/deepgram/v1/speak", "/deepgram/speak")
NOVA_2_STREAMING_KEY: Final = "deepgram/streaming/nova-2"
pytestmark: Final = pytest.mark.usefixtures("local_model_cost_map")
@@ -62,6 +69,7 @@ class _RelayCall:
forward_headers: bool
endpoint: str
accept_websocket: bool
+ meter: WebSocketSessionMeter | None = None
class _FakeRelay:
@@ -78,6 +86,7 @@ class _FakeRelay:
forward_headers: bool,
endpoint: str,
accept_websocket: bool,
+ meter: WebSocketSessionMeter | None = None,
) -> None:
self.calls.append(
_RelayCall(
@@ -87,6 +96,7 @@ class _FakeRelay:
forward_headers=forward_headers,
endpoint=endpoint,
accept_websocket=accept_websocket,
+ meter=meter,
)
)
@@ -472,3 +482,191 @@ def test_deepgram_listen_echoes_the_browser_subprotocol_that_carries_the_litellm
MappingProxyType({"Authorization": "Token dg-provider-key"})
]
assert [call.forward_headers for call in relay.calls] == [False]
+
+
+async def _serve_speak(websocket: _FakeWebSocket, user_api_key_dict: UserAPIKeyAuth | None = None) -> _FakeRelay:
+ relay: Final = _FakeRelay()
+ await deepgram_speak_websocket_route(
+ websocket=websocket,
+ user_api_key_dict=user_api_key_dict or UserAPIKeyAuth(),
+ relay=relay,
+ )
+ return relay
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("path", SPEAK_PATHS)
+async def test_deepgram_speak_relays_with_provider_auth_and_meters_the_session(
+ path: str, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.delenv("DEEPGRAM_API_BASE", raising=False)
+ monkeypatch.setenv("DEEPGRAM_API_KEY", "dg-provider-key")
+ websocket: Final = _FakeWebSocket(path, "encoding=linear16&sample_rate=24000")
+ caller: Final = UserAPIKeyAuth(api_key="sk-litellm-virtual", team_id="team-tts")
+ target: Final = "wss://api.deepgram.com/v1/speak?encoding=linear16&sample_rate=24000&model=aura-asteria-en"
+
+ relay: Final = await _serve_speak(websocket, caller)
+
+ assert relay.calls == [
+ _RelayCall(
+ target=target,
+ custom_headers=MappingProxyType({"Authorization": "Token dg-provider-key"}),
+ user_api_key_dict=caller,
+ forward_headers=False,
+ endpoint=path,
+ accept_websocket=False,
+ meter=DeepgramSpeakMeter(target, litellm.model_cost["deepgram/aura"]["input_cost_per_character"]),
+ )
+ ]
+ assert websocket.accepts == [None]
+ assert websocket.closed is None
+
+
+@pytest.mark.asyncio
+async def test_deepgram_speak_bills_at_the_connect_time_rate_after_a_cost_map_reload_drops_the_row(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.delenv("DEEPGRAM_API_BASE", raising=False)
+ monkeypatch.setenv("DEEPGRAM_API_KEY", "dg-provider-key")
+ rate: Final = litellm.model_cost["deepgram/aura-2"]["input_cost_per_character"]
+
+ relay: Final = await _serve_speak(_FakeWebSocket("/deepgram/v1/speak", "model=aura-2-thalia-en"))
+ meters: Final = [call.meter for call in relay.calls if isinstance(call.meter, DeepgramSpeakMeter)]
+ adopt_model_cost_map({key: row for key, row in litellm.model_cost.items() if key != "deepgram/aura-2"})
+
+ assert [meter.price(47).response_cost for meter in meters] == [47 * rate]
+
+
+@pytest.mark.asyncio
+async def test_deepgram_speak_honours_server_configured_api_base(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("DEEPGRAM_API_BASE", "http://localhost:8080/v1")
+ monkeypatch.setenv("DEEPGRAM_API_KEY", "dg-provider-key")
+
+ relay: Final = await _serve_speak(_FakeWebSocket("/deepgram/v1/speak", "model=aura-2-thalia-en"))
+
+ assert [call.target for call in relay.calls] == ["ws://localhost:8080/v1/speak?model=aura-2-thalia-en"]
+
+
+@pytest.mark.asyncio
+async def test_deepgram_speak_closes_cleanly_when_provider_credentials_missing(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.delenv("DEEPGRAM_API_KEY", raising=False)
+ websocket: Final = _FakeWebSocket("/deepgram/v1/speak", "model=aura-2-thalia-en")
+
+ relay: Final = await _serve_speak(websocket)
+
+ assert relay.calls == []
+ assert websocket.accepts == []
+ assert websocket.closed == (
+ 1011,
+ "Required 'DEEPGRAM_API_KEY' in environment to make pass-through calls to Deepgram.",
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("query", "missing_key"),
+ [
+ pytest.param("model=aura-99-x-en", "deepgram/aura-99", id="generation without a row"),
+ pytest.param("model=nova-3", "deepgram/nova-3", id="listen model has no per-character rate"),
+ ],
+)
+async def test_deepgram_speak_refuses_voices_it_cannot_price(
+ query: str, missing_key: str, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.delenv("DEEPGRAM_API_BASE", raising=False)
+ monkeypatch.setenv("DEEPGRAM_API_KEY", "dg-provider-key")
+ websocket: Final = _FakeWebSocket("/deepgram/v1/speak", query)
+
+ relay: Final = await _serve_speak(websocket)
+
+ assert relay.calls == []
+ assert websocket.closed == (
+ 1008,
+ f"No streaming price for '{missing_key}': add it to the model cost map to enable it",
+ )
+
+
+@pytest.mark.asyncio
+async def test_deepgram_speak_refusal_for_a_long_unpriced_model_fits_a_close_frame(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.delenv("DEEPGRAM_API_BASE", raising=False)
+ monkeypatch.setenv("DEEPGRAM_API_KEY", "dg-provider-key")
+ websocket: Final = _FakeWebSocket("/deepgram/v1/speak", f"model={'x' * 200}")
+
+ relay: Final = await _serve_speak(websocket)
+
+ assert relay.calls == []
+ assert websocket.closed is not None
+ assert (websocket.closed[0], len(websocket.closed[1].encode())) == (1008, WEBSOCKET_CLOSE_REASON_MAX_BYTES)
+
+
+@pytest.mark.parametrize("path", SPEAK_PATHS)
+@pytest.mark.parametrize(
+ "query",
+ [
+ pytest.param("model=aura-2-thalia-en", id="allowed voice"),
+ pytest.param("model=aura-2-thalia-en&model=aura-asteria-en", id="second voice stripped"),
+ ],
+)
+def test_deepgram_speak_relays_only_the_voice_the_key_was_checked_against(
+ path: str, query: str, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A key allowed only one voice must not reach another by repeating ``model``: the real key auth path must see
+ the voice the upstream target will carry"""
+ monkeypatch.delenv("DEEPGRAM_API_BASE", raising=False)
+ monkeypatch.setenv("DEEPGRAM_API_KEY", "dg-provider-key")
+ monkeypatch.setattr(litellm, "max_budget", 0.0)
+ cache: Final = asyncio.run(_cache_restricted_key("sk-only-thalia", ["aura-2-thalia-en"]))
+ relay: Final = _FakeRelay()
+ client: Final = TestClient(_app_with_relay(relay))
+
+ with patch.multiple( # test-quality-ok: the real key auth path reads these proxy_server globals and has no injection seam
+ "litellm.proxy.proxy_server",
+ master_key="sk-master",
+ prisma_client=MagicMock(),
+ user_api_key_cache=cache,
+ llm_model_list=None,
+ llm_router=None,
+ ):
+ with client.websocket_connect(f"{path}?{query}", headers={"Authorization": "Bearer sk-only-thalia"}):
+ pass
+
+ assert [(call.target, call.endpoint) for call in relay.calls] == [
+ ("wss://api.deepgram.com/v1/speak?model=aura-2-thalia-en", path)
+ ]
+
+
+@pytest.mark.parametrize(
+ "query",
+ [
+ pytest.param("model=aura-asteria-en", id="denied voice named"),
+ pytest.param("", id="voice omitted, default denied"),
+ ],
+)
+def test_deepgram_speak_refuses_a_voice_the_key_is_not_allowed(query: str, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("DEEPGRAM_API_BASE", raising=False)
+ monkeypatch.setenv("DEEPGRAM_API_KEY", "dg-provider-key")
+ monkeypatch.setattr(litellm, "max_budget", 0.0)
+ cache: Final = asyncio.run(_cache_restricted_key("sk-only-thalia", ["aura-2-thalia-en"]))
+ relay: Final = _FakeRelay()
+ client: Final = TestClient(_app_with_relay(relay))
+
+ with patch.multiple( # test-quality-ok: the real key auth path reads these proxy_server globals and has no injection seam
+ "litellm.proxy.proxy_server",
+ master_key="sk-master",
+ prisma_client=MagicMock(),
+ user_api_key_cache=cache,
+ llm_model_list=None,
+ llm_router=None,
+ ):
+ with pytest.raises(WebSocketDisconnect) as disconnect:
+ with client.websocket_connect(
+ f"/deepgram/v1/speak?{query}", headers={"Authorization": "Bearer sk-only-thalia"}
+ ):
+ pass
+
+ assert disconnect.value.code == 1008
+ assert relay.calls == []
diff --git a/tests/unit/proxy/pass_through_endpoints/test_watsonx_proxy_route.py b/tests/unit/proxy/pass_through_endpoints/test_watsonx_proxy_route.py
index 5500bb0aad9..e0a9bda0967 100644
--- a/tests/unit/proxy/pass_through_endpoints/test_watsonx_proxy_route.py
+++ b/tests/unit/proxy/pass_through_endpoints/test_watsonx_proxy_route.py
@@ -437,3 +437,116 @@ class TestWatsonxProxyRoute:
call_args["target"]
== f"https://us-south.ml.cloud.ibm.com/{endpoint_path}"
)
+
+
+_WATSONX_ENV = (
+ "WATSONX_API_KEY",
+ "WATSONX_APIKEY",
+ "WX_API_KEY",
+ "WATSONX_API_BASE",
+ "WATSONX_URL",
+ "WX_URL",
+ "WML_URL",
+ "WATSONX_TOKEN",
+ "WATSONX_ZENAPIKEY",
+)
+
+
+def _watsonx_deployment(api_key: str, api_base: str, use_in_pass_through: bool) -> dict:
+ return {
+ "model_name": "granite",
+ "litellm_params": {
+ "model": "watsonx/ibm/granite-3-8b-instruct",
+ "api_key": api_key,
+ "api_base": api_base,
+ "project_id": "proj",
+ "use_in_pass_through": use_in_pass_through,
+ },
+ }
+
+
+def _post_request(body: dict) -> MagicMock:
+ request = MagicMock(spec=Request)
+ request.method = "POST"
+ request.query_params = {}
+ request.headers = {"content-type": "application/json"}
+ request.json = AsyncMock(return_value=body)
+ return request
+
+
+def _iam_response(token: str) -> MagicMock:
+ response = MagicMock()
+ response.json.return_value = {"access_token": token, "expires_in": 3600}
+ return response
+
+
+async def _route_target_and_iam_apikey(
+ monkeypatch, llm_router: litellm.Router, env: dict[str, str]
+) -> tuple[str, str, str]:
+ from litellm.llms.watsonx.common_utils import iam_token_cache
+ from litellm.proxy.pass_through_endpoints.passthrough_endpoint_router import PassthroughEndpointRouter
+
+ for name in _WATSONX_ENV:
+ monkeypatch.delenv(name, raising=False)
+ for name, value in env.items():
+ monkeypatch.setenv(name, value)
+ monkeypatch.setattr(litellm, "credential_list", [])
+ iam_token_cache.flush_cache()
+ monkeypatch.setattr(
+ "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router",
+ PassthroughEndpointRouter(llm_router_getter=lambda: llm_router),
+ )
+ endpoint_func = AsyncMock(return_value={"results": []})
+ with (
+ patch.object(litellm.module_level_client, "post", return_value=_iam_response("iam-token-1")) as iam_post,
+ patch(
+ "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route",
+ return_value=endpoint_func,
+ ) as create_route,
+ ):
+ await watsonx_proxy_route(
+ endpoint="ml/v1/text/generation",
+ request=_post_request({"input": "hi", "project_id": "proj"}),
+ fastapi_response=MagicMock(spec=Response),
+ user_api_key_dict=MagicMock(spec=UserAPIKeyAuth),
+ )
+ route_kwargs = create_route.call_args.kwargs
+ return (
+ route_kwargs["target"],
+ route_kwargs["custom_headers"]["Authorization"],
+ iam_post.call_args.kwargs["data"]["apikey"],
+ )
+
+
+@pytest.mark.asyncio
+async def test_watsonx_route_uses_the_use_in_pass_through_deployment_key_and_base(monkeypatch):
+ llm_router = litellm.Router(
+ model_list=[_watsonx_deployment("wx-deployment-key", "https://eu-de.ml.cloud.ibm.com", use_in_pass_through=True)]
+ )
+
+ target, authorization, iam_apikey = await _route_target_and_iam_apikey(monkeypatch, llm_router, env={})
+
+ assert target == "https://eu-de.ml.cloud.ibm.com/ml/v1/text/generation"
+ assert authorization == "Bearer iam-token-1"
+ assert iam_apikey == "wx-deployment-key"
+
+
+@pytest.mark.asyncio
+async def test_watsonx_route_falls_back_to_env_when_no_deployment_is_flagged(monkeypatch):
+ llm_router = litellm.Router(
+ model_list=[_watsonx_deployment("wx-deployment-key", "https://eu-de.ml.cloud.ibm.com", use_in_pass_through=False)]
+ )
+
+ target, authorization, iam_apikey = await _route_target_and_iam_apikey(
+ monkeypatch,
+ llm_router,
+ env={
+ "WX_API_KEY": "wx-env-key",
+ "WATSONX_API_KEY": "watsonx-env-key",
+ "WATSONX_API_BASE": "https://us-south.ml.cloud.ibm.com",
+ },
+ )
+
+ assert target == "https://us-south.ml.cloud.ibm.com/ml/v1/text/generation"
+ assert authorization == "Bearer iam-token-1"
+ assert iam_apikey == "wx-env-key"
diff --git a/tests/unit/proxy/response_polling/test_response_polling_handler.py b/tests/unit/proxy/response_polling/test_response_polling_handler.py
index 087b273548a..d6df7a6c69a 100644
--- a/tests/unit/proxy/response_polling/test_response_polling_handler.py
+++ b/tests/unit/proxy/response_polling/test_response_polling_handler.py
@@ -1917,3 +1917,71 @@ async def test_create_initial_state_rejects_request_metadata_that_is_not_an_obje
assert [(error["loc"], error["type"], repr(error["input"])) for error in rejected.value.errors()] == [
(("metadata",), "dict_type", repr(metadata))
]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "delta_type",
+ [
+ "response.output_text.delta",
+ "response.reasoning_text.delta",
+ "response.reasoning_summary_text.delta",
+ "response.reasoning.delta",
+ None,
+ ],
+)
+async def test_background_timings_survive_retrieval_after_completion(delta_type):
+ from litellm.caching.redis_cache import RedisCache
+ from litellm.proxy.response_polling.background_streaming import background_streaming_task
+
+ cache = {}
+ redis = AsyncMock(spec=RedisCache)
+ redis.async_get_cache.side_effect = lambda key: cache.get(key)
+
+ async def save_cache(key, value, **kwargs):
+ cache[key] = value
+
+ redis.async_set_cache.side_effect = save_cache
+ handler = ResponsePollingHandler(redis_cache=redis)
+ await handler.create_initial_state("litellm_poll_timing", {"model": "gpt-4o"})
+ clock = [100.0]
+
+ async def stream():
+ clock[0] = 101.0
+ yield 'data: {"type":"response.output_text.delta","delta":""}\n\n'
+ yield 'data: {"type":"response.mcp_call.in_progress"}\n\n'
+ if delta_type:
+ clock[0] = 102.0
+ yield f"data: {json.dumps({'type': delta_type, 'delta': 'Hello'})}\n\n"
+ partial = await handler.get_state("litellm_poll_timing")
+ assert partial is not None
+ assert partial["status"] == "in_progress"
+ assert partial["litellm_response_timing"] == {"time_to_first_token_ms": 2000.0}
+ clock[0] = 110.0
+ yield 'data: {"type":"response.completed","response":{"status":"completed","output":[]}}\n\n'
+ clock[0] = 5000.0
+ yield "data: [DONE]\n\n"
+
+ from starlette.responses import StreamingResponse
+
+ with (
+ patch(
+ "litellm.proxy.response_polling.background_streaming.perf_counter",
+ create=True,
+ side_effect=lambda: clock[0],
+ ),
+ patch(
+ # test-quality-ok: processor is constructed inside the task; supply the upstream SSE boundary like sibling tests
+ "litellm.proxy.response_polling.background_streaming.ProxyBaseLLMRequestProcessing"
+ ) as processor,
+ ):
+ processor.return_value.base_process_llm_request = AsyncMock(return_value=StreamingResponse(stream()))
+ await background_streaming_task(**_make_background_streaming_kwargs("litellm_poll_timing", handler))
+ state = await handler.get_state("litellm_poll_timing")
+ assert state is not None
+ expected = {"total_latency_ms": 10000.0}
+ if delta_type:
+ expected["time_to_first_token_ms"] = 2000.0
+ assert state["litellm_response_timing"] == expected
+ clock[0] = 10000.0
+ assert (await handler.get_state("litellm_poll_timing"))["litellm_response_timing"] == expected
diff --git a/tests/unit/proxy/test_proxy_server.py b/tests/unit/proxy/test_proxy_server.py
index 6e494f0b24b..e4af36d99f2 100644
--- a/tests/unit/proxy/test_proxy_server.py
+++ b/tests/unit/proxy/test_proxy_server.py
@@ -4561,3 +4561,88 @@ async def test_text_completion_provider_rejection_captures_unknown_usage(
assert envelope["usage"] is None
assert envelope["usage_source"] == "unknown"
assert envelope["pricing"]["reported_cost"] is None
+
+
+@pytest.mark.parametrize(
+ "polling,has_redis,native,expected",
+ [
+ (False, True, False, False),
+ ("all", False, False, False),
+ ("all", True, True, False),
+ ("all", True, False, True),
+ (["openai"], True, False, True),
+ (["anthropic"], True, False, False),
+ ],
+)
+def test_model_groups_advertise_cache_backed_background_chat(
+ monkeypatch: pytest.MonkeyPatch,
+ polling: bool | str | list[str],
+ has_redis: bool,
+ native: bool,
+ expected: bool,
+) -> None:
+ from litellm.caching.redis_cache import RedisCache
+ from litellm.proxy import proxy_server
+
+ router: Final = litellm.Router(
+ model_list=[
+ {
+ "model_name": "research-chat",
+ "litellm_params": {"model": "openai/gpt-6.1-sol", "api_key": "fake-test-key"},
+ }
+ ]
+ )
+ monkeypatch.setattr(proxy_server, "polling_via_cache_enabled", polling)
+ monkeypatch.setattr(
+ proxy_server, "redis_usage_cache", RedisCache(host="localhost", port=6379) if has_redis else None
+ )
+ monkeypatch.setattr(proxy_server, "native_background_mode", ["research-chat"] if native else [])
+ groups: Final = proxy_server.get_model_group_info(router, ["research-chat"], None)
+ assert groups[0].supports_background_chat is expected
+
+
+@pytest.mark.parametrize("config_owned", [False, True])
+def test_background_response_settings_refresh_and_respect_config(
+ monkeypatch: pytest.MonkeyPatch,
+ config_owned: bool,
+) -> None:
+ from litellm.proxy import proxy_server
+
+ monkeypatch.setattr(proxy_server, "polling_via_cache_enabled", False)
+ monkeypatch.setattr(proxy_server, "native_background_mode", [])
+ monkeypatch.setattr(proxy_server, "polling_cache_ttl", 3600)
+ config: Final = proxy_server.ProxyConfig()
+ if config_owned:
+ config.litellm_settings.load_yaml(
+ {
+ "responses": {
+ "background_mode": {
+ "polling_via_cache": ["openai"],
+ "native_background_mode": ["config-native"],
+ "ttl": 900,
+ }
+ },
+ }
+ )
+ config._apply_litellm_settings_db_values(
+ {
+ "responses": {
+ "background_mode": {
+ "polling_via_cache": "all",
+ "native_background_mode": ["db-native"],
+ "ttl": 7200,
+ }
+ },
+ }
+ )
+ assert proxy_server.polling_via_cache_enabled == (["openai"] if config_owned else "all")
+ assert proxy_server.native_background_mode == (["config-native"] if config_owned else ["db-native"])
+ assert proxy_server.polling_cache_ttl == (900 if config_owned else 7200)
+ config._apply_litellm_settings_db_values(
+ {
+ "responses": {"background_mode": {"polling_via_cache": False}},
+ }
+ )
+ assert proxy_server.polling_via_cache_enabled == (["openai"] if config_owned else False)
+ assert proxy_server.native_background_mode == (["config-native"] if config_owned else [])
+ assert proxy_server.polling_cache_ttl == (900 if config_owned else 3600)
diff --git a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
index 4360655c8ca..ea39d5d59e2 100644
--- a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
+++ b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
@@ -4488,3 +4488,114 @@ def test_usage_default_range_reports_config_precedence(mock_auth, monkeypatch: p
assert response.status_code == 200, response.text
assert response.json()["values"]["usage_default_time_range"] == "month_to_date"
assert response.json()["source"]["usage_default_time_range"] == "config"
+
+
+@pytest.fixture
+def background_chat_config(monkeypatch: pytest.MonkeyPatch):
+ from copy import deepcopy
+ from unittest.mock import AsyncMock, MagicMock
+
+ from litellm.caching.redis_cache import RedisCache
+ from litellm.proxy import proxy_server
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+ from litellm.proxy.config_resolvers import SettingsStore
+
+ config = {
+ "litellm_settings": {
+ "callbacks": ["prometheus"],
+ "responses": {
+ "background_mode": {
+ "polling_via_cache": False,
+ "ttl": 7200,
+ "native_background_mode": ["native-model"],
+ },
+ },
+ }
+ }
+ store = SettingsStore("litellm_settings")
+ monkeypatch.setattr(proxy_server.proxy_config, "litellm_settings", store)
+ monkeypatch.setattr(proxy_server.proxy_config, "_settings_stores", {"litellm_settings": store})
+ monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
+ monkeypatch.setattr(proxy_server, "store_model_in_db", True)
+ monkeypatch.setattr(proxy_server, "redis_usage_cache", RedisCache(host="localhost", port=6379))
+ monkeypatch.setattr(proxy_server, "polling_via_cache_enabled", False)
+ monkeypatch.setattr(proxy_server, "native_background_mode", [])
+ monkeypatch.setattr(proxy_server, "polling_cache_ttl", 3600)
+ monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(side_effect=lambda: deepcopy(config)))
+
+ async def save_config(*, new_config):
+ config.clear()
+ config.update(deepcopy(new_config))
+
+ save = AsyncMock(side_effect=save_config)
+ audit = AsyncMock()
+ monkeypatch.setattr(proxy_server.proxy_config, "save_config", save)
+ monkeypatch.setattr(proxy_server, "create_config_audit_log", audit)
+ app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
+ user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
+ )
+ yield config, save, audit
+ app.dependency_overrides.pop(user_api_key_auth, None)
+
+
+def test_background_chat_settings_persist_preserving_existing_options(background_chat_config, monkeypatch):
+ from litellm.proxy import proxy_server
+
+ config, _, _ = background_chat_config
+ response = client.patch("/update/background_chat_settings", json={"enabled": True})
+ assert response.status_code == 200
+ assert response.json()["enabled"] is True
+ assert proxy_server.polling_via_cache_enabled is False
+ assert config["litellm_settings"]["responses"]["background_mode"] == {
+ "polling_via_cache": "all",
+ "ttl": 7200,
+ "native_background_mode": ["native-model"],
+ }
+ assert config["litellm_settings"]["callbacks"] == ["prometheus"]
+ assert client.get("/get/background_chat_settings").json()["enabled"] is True
+ proxy_server.proxy_config._apply_litellm_settings_db_values(config["litellm_settings"])
+ assert proxy_server.polling_via_cache_enabled == "all"
+ disabled = client.patch("/update/background_chat_settings", json={"enabled": False})
+ assert disabled.status_code == 200
+ assert disabled.json()["enabled"] is False
+ proxy_server.proxy_config._apply_litellm_settings_db_values(config["litellm_settings"])
+ assert proxy_server.polling_via_cache_enabled is False
+
+
+@pytest.mark.parametrize("blocker", ["role", "redis", "config", "persistence"])
+def test_background_chat_settings_reject_unavailable_updates(background_chat_config, monkeypatch, blocker):
+ from litellm.proxy import proxy_server
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+
+ config, save, _ = background_chat_config
+ if blocker == "role":
+ app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
+ user_id="viewer", user_role=LitellmUserRoles.INTERNAL_USER
+ )
+ elif blocker == "redis":
+ monkeypatch.setattr(proxy_server, "redis_usage_cache", None)
+ assert client.get("/get/background_chat_settings").json()["redis_configured"] is False
+ elif blocker == "config":
+ proxy_server.proxy_config.litellm_settings.load_yaml(config["litellm_settings"])
+ assert client.get("/get/background_chat_settings").json()["managed_by_config"] is True
+ else:
+ monkeypatch.setattr(proxy_server, "store_model_in_db", False)
+ assert client.get("/get/background_chat_settings").json()["can_save"] is False
+ response = client.patch("/update/background_chat_settings", json={"enabled": True})
+ assert response.status_code == (403 if blocker == "role" else 400)
+ save.assert_not_awaited()
+ assert config["litellm_settings"]["responses"]["background_mode"]["polling_via_cache"] is False
+
+
+def test_background_chat_settings_failed_save_does_not_enable_runtime(background_chat_config):
+ from litellm.proxy import proxy_server
+
+ _, save, audit = background_chat_config
+ save.side_effect = RuntimeError("Database write failed")
+ with pytest.raises(RuntimeError, match="Database write failed"):
+ client.patch("/update/background_chat_settings", json={"enabled": True})
+ assert client.get("/get/background_chat_settings").json()["enabled"] is False
+ assert proxy_server.polling_via_cache_enabled is False
+ audit.assert_not_awaited()
diff --git a/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py b/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py
index bc73ef8598a..5ae985ea7e5 100644
--- a/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py
+++ b/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py
@@ -1598,6 +1598,53 @@ async def test_previous_response_id_keeps_the_tools_an_earlier_codex_tool_search
assert prisma_client.db.queries == 1
+@pytest.mark.asyncio
+async def test_a_codex_additional_tools_item_sends_claude_its_tools_but_not_its_text() -> None:
+ anthropic: Final = _RecordingRawAnthropicMessages()
+
+ await litellm.aresponses(
+ model=_MODEL,
+ input=[
+ {"type": "additional_tools", "role": "developer", "content": "Reply PINEAPPLE.", "tools": [_SHELL_TOOL]},
+ {"role": "user", "content": "Hi"},
+ ],
+ api_key="sk-ant-fake",
+ client=AsyncHTTPHandler(transport=httpx.MockTransport(anthropic)),
+ )
+
+ sent: Final = json.loads(anthropic.body)
+ assert [tool["name"] for tool in sent["tools"]] == ["shell"]
+ assert "PINEAPPLE" not in json.dumps(sent)
+
+
+@pytest.mark.asyncio
+async def test_previous_response_id_names_a_malformed_item_after_a_codex_additional_tools_item_by_its_index() -> None:
+ weather_tool: Final = {
+ "type": "function",
+ "name": "get_weather",
+ "parameters": {"type": "object", "properties": {}},
+ }
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", _FakePrismaClient([_SEARCH_TURN, _CALENDAR_TURN])),
+ pytest.raises(litellm.BadRequestError) as raised,
+ ):
+ await litellm.aresponses(
+ model=_MODEL,
+ previous_response_id="chatcmpl-calendar-turn",
+ input=[
+ {"type": "additional_tools", "role": "developer", "tools": [weather_tool]},
+ {"type": "function_call_output", "call_id": "toolu_standup", "output": "Created Standup."},
+ {"role": ["user"], "content": "Now create another event titled Retro."},
+ ],
+ tools=[_CODEX_TOOL_SEARCH, _SHELL_TOOL],
+ api_key="sk-ant-fake",
+ client=AsyncHTTPHandler(transport=httpx.MockTransport(_RecordingRawAnthropicMessages())),
+ )
+
+ assert "'input[2].role'" in raised.value.message
+
+
_REFUSAL_EXPLANATION: Final = ANTHROPIC_DOCS_REFUSAL_MESSAGE["stop_details"]["explanation"]
_ANSWER_BEFORE_REFUSAL: Final = {"type": "text", "text": "Here is the start."}
@@ -1808,7 +1855,11 @@ _RAN_SEARCH_RESULT: Final = {
{"type": "web_search_result", "url": "https://tokyo.example", "title": "Tokyo", "encrypted_content": "e"}
],
}
-_THINKING_BEFORE_SEARCHES: Final = {"type": "thinking", "thinking": "Find each sister city in turn.", "signature": "sig"}
+_THINKING_BEFORE_SEARCHES: Final = {
+ "type": "thinking",
+ "thinking": "Find each sister city in turn.",
+ "signature": "sig",
+}
_TEXT_BEFORE_PENDING_SEARCH: Final = {"type": "text", "text": "Tokyo pairs with Paris. Checking Paris next."}
_PENDING_SEARCH: Final = {
"type": "server_tool_use",
diff --git a/tests/unit/responses/litellm_completion_transformation/test_count_request.py b/tests/unit/responses/litellm_completion_transformation/test_count_request.py
index 2080a091160..e0a5997eac7 100644
--- a/tests/unit/responses/litellm_completion_transformation/test_count_request.py
+++ b/tests/unit/responses/litellm_completion_transformation/test_count_request.py
@@ -86,7 +86,13 @@ async def test_claude_count_request_counts_additional_tools_input_items_as_tools
policy: Final = DropPolicy(drop_params=False, additional_drop_params=frozenset())
hoisted: Final = await _claude_count(
- {"input": [{"type": "additional_tools", "tools": [_WEATHER_TOOL]}, _QUESTION]}, policy
+ {
+ "input": [
+ {"type": "additional_tools", "role": "developer", "content": "Reply PINEAPPLE.", "tools": [_WEATHER_TOOL]},
+ _QUESTION,
+ ]
+ },
+ policy,
)
assert isinstance(hoisted, ChatCountRequest)
@@ -129,6 +135,17 @@ async def test_claude_count_request_rejects_a_malformed_tool_flag_without_drop_p
assert "defer_loading" in flagged.message
+@pytest.mark.asyncio
+async def test_claude_count_request_leaves_a_non_string_role_for_the_count_endpoint_to_judge() -> None:
+ counted: Final = await _claude_count(
+ {"input": [{"type": "additional_tools", "tools": [_WEATHER_TOOL]}, _QUESTION, {"role": ["user"], "content": "?"}]},
+ DropPolicy(drop_params=False, additional_drop_params=frozenset()),
+ )
+
+ assert isinstance(counted, ChatCountRequest)
+ assert [message.get("role") for message in counted.messages] == ["user", ["user"]]
+
+
@pytest.mark.asyncio
async def test_claude_count_request_leaves_out_tools_the_deployment_drops() -> None:
policy: Final = DropPolicy(drop_params=False, additional_drop_params=frozenset({"tools"}))
diff --git a/tests/unit/responses/litellm_completion_transformation/test_handler.py b/tests/unit/responses/litellm_completion_transformation/test_handler.py
index d731bf5f41c..4794f3701c5 100644
--- a/tests/unit/responses/litellm_completion_transformation/test_handler.py
+++ b/tests/unit/responses/litellm_completion_transformation/test_handler.py
@@ -246,6 +246,26 @@ async def test_bridged_turn_with_a_client_field_named_previous_response_client_i
assert isinstance(response, ResponsesAPIResponse)
+@pytest.mark.asyncio
+@pytest.mark.parametrize("client_field", ["caller_input", "names_non_string_fields"])
+async def test_bridged_turn_with_a_client_field_named_like_a_bridge_keyword_reaches_the_provider(client_field: str):
+ provider: Final = _RecordingAnthropicHandler(_ANTHROPIC_MESSAGE_PAYLOAD)
+ client: Final = AsyncHTTPHandler()
+ client.client = httpx.AsyncClient(transport=httpx.MockTransport(provider))
+
+ response = await litellm.aresponses(
+ model="azure_ai/claude-sonnet-4-6",
+ api_base="https://fake-foundry-resource.services.ai.azure.com",
+ api_key="fake-api-key",
+ input="Double it",
+ client=client,
+ **{client_field: "from the client body"},
+ )
+
+ assert provider.request_body is not None, "the bridged turn never reached the provider"
+ assert isinstance(response, ResponsesAPIResponse)
+
+
class _StoredTurns:
def __init__(self, rows: tuple[Mapping[str, object], ...]) -> None:
self.rows: Final = rows
@@ -310,3 +330,32 @@ async def test_bridged_next_turn_replays_a_turn_stored_under_the_id_the_client_h
("assistant", json.dumps([{"type": "text", "text": "Noted."}])),
("user", json.dumps([{"type": "text", "text": "Double it"}])),
]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("client_field", ["caller_input", "names_non_string_fields"])
+async def test_bridged_follow_up_after_a_client_tool_search_with_a_client_field_named_like_a_bridge_keyword_reaches_the_provider(
+ client_field: str,
+):
+ previous_response_id: Final = ResponsesAPIRequestUtils._build_responses_api_response_id(
+ custom_llm_provider="azure_ai", model_id="deployment-a", response_id="chatcmpl-turn-one"
+ )
+ stored_turns: Final = _StoredTurns((_stored_mcp_turn(previous_response_id),))
+ provider: Final = _RecordingAnthropicHandler(_ANTHROPIC_MESSAGE_PAYLOAD)
+ client: Final = AsyncHTTPHandler()
+ client.client = httpx.AsyncClient(transport=httpx.MockTransport(provider))
+
+ with patch("litellm.proxy.proxy_server.prisma_client", SimpleNamespace(db=stored_turns)):
+ response = await litellm.aresponses(
+ model="azure_ai/claude-sonnet-4-6",
+ api_base="https://fake-foundry-resource.services.ai.azure.com",
+ api_key="fake-api-key",
+ input="Double it",
+ tools=[{"type": "tool_search", "execution": "client"}],
+ previous_response_id=previous_response_id,
+ client=client,
+ **{client_field: "from the client body"},
+ )
+
+ assert provider.request_body is not None, "the bridged turn never reached the provider"
+ assert isinstance(response, ResponsesAPIResponse)
diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py
index bb1f5104200..b4cd606374a 100644
--- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py
+++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py
@@ -18,6 +18,7 @@ from openai.types.responses.response_function_web_search import (
from pydantic import TypeAdapter, ValidationError
import litellm
+from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import message_field
from litellm.constants import CALLER_SESSION_ID_METADATA_KEY
from litellm.integrations.anthropic_cache_control_hook import MAX_CACHE_CONTROL_BLOCKS, AnthropicCacheControlHook
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR
@@ -7189,3 +7190,207 @@ async def test_bridge_partial_response_on_disconnect_bills_only_the_input_since_
assert await _input_tokens_billed_on_disconnect(
cast(ResponseInputParam, [*history_it_replaces, *since_compaction])
) == await _input_tokens_billed_on_disconnect(since_compaction)
+
+
+_ANTHROPIC_MODEL: Final = "anthropic/claude-sonnet-5-5"
+_WEATHER_ASK: Final = {"role": "user", "content": "Weather in Paris?"}
+
+
+def _weather_call(**fields: object) -> dict[str, object]:
+ return {"type": "function_call", "name": "get_weather", "arguments": "{}", **fields}
+
+
+def _bridge_to(model: str, history: list[dict[str, object]], **request: object) -> dict:
+ return LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
+ model=model,
+ input=cast(ResponseInputParam, history),
+ responses_api_request=cast(ResponsesAPIOptionalRequestParams, request),
+ names_non_string_fields=True,
+ )
+
+
+@pytest.mark.parametrize("output_type", ["function_call_output", "custom_tool_call_output"])
+@pytest.mark.parametrize(("call_id", "type_name"), [({"id": "call_weather"}, "dict"), (["call_weather"], "list")])
+def test_a_tool_output_with_an_unhashable_call_id_is_a_400_naming_its_input_item(
+ output_type: str, call_id: object, type_name: str
+) -> None:
+ history: Final = cast(
+ ResponseInputParam,
+ [_WEATHER_ASK, _weather_call(call_id="call_weather"), {"type": output_type, "call_id": call_id, "output": "sunny"}],
+ )
+
+ with pytest.raises(litellm.BadRequestError) as raised:
+ LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
+ input=history, responses_api_request={}
+ )
+
+ assert raised.value.message == (
+ f"litellm.BadRequestError: Invalid type for 'input[2].call_id': expected a string, got {type_name}"
+ )
+
+
+def test_a_malformed_call_id_after_a_compaction_item_is_named_by_its_index_in_the_request() -> None:
+ history: Final = cast(
+ ResponseInputParam,
+ [
+ {"role": "user", "content": "Plan a Tokyo trip."},
+ {"role": "assistant", "content": "Day one, Asakusa."},
+ {"type": "compaction", "id": "cmp_litellm", "encrypted_content": json.dumps(_COMPACTION_BLOCK)},
+ _weather_call(call_id="call_weather"),
+ {"type": "function_call_output", "call_id": {"id": "call_weather"}, "output": "sunny"},
+ ],
+ )
+
+ with pytest.raises(litellm.BadRequestError) as raised:
+ LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
+ input=history, responses_api_request={}, only_since_compaction=True
+ )
+
+ assert "'input[4].call_id'" in raised.value.message
+
+
+@pytest.mark.parametrize("call_id", [7, {"id": "call_weather"}, ["call_weather"]], ids=["int", "object", "list"])
+def test_a_conversion_failure_elsewhere_is_never_blamed_on_a_call_id(call_id: object) -> None:
+ history: Final = cast(
+ ResponseInputParam,
+ [
+ _WEATHER_ASK,
+ {"type": ["message"], "role": "user", "content": "Go on"},
+ {"type": "function_call_output", "call_id": call_id, "output": "sunny"},
+ ],
+ )
+
+ with pytest.raises((TypeError, litellm.BadRequestError)) as raised:
+ LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
+ input=history, responses_api_request={}
+ )
+
+ assert "call_id" not in str(raised.value)
+
+
+def test_a_tool_output_with_an_int_call_id_converts_like_its_string_form() -> None:
+ def converted(call_id: object) -> list[object]:
+ return list(
+ LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
+ input=cast(
+ ResponseInputParam,
+ [_WEATHER_ASK, {"type": "function_call_output", "call_id": call_id, "output": "sunny"}],
+ ),
+ responses_api_request={},
+ )
+ )
+
+ assert converted(7) == converted("7")
+
+
+def test_a_non_string_role_still_converts_for_a_target_that_judges_it_itself() -> None:
+ history: Final = [{"role": "user", "content": "Hi"}, {"role": ["user"], "content": "Go on"}]
+
+ shared: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
+ input=cast(ResponseInputParam, history), responses_api_request={}
+ )
+ bridged: Final = _bridge_to("gemini/gemini-3.8-flash", history)
+
+ assert [message_field(message, "role") for message in shared] == ["user", ["user"]]
+ assert [message_field(message, "role") for message in bridged["messages"]] == ["user", ["user"]]
+
+
+@pytest.mark.parametrize(
+ ("history", "field_path", "type_name"),
+ [
+ pytest.param(
+ [{"role": "user", "content": "Hi"}, {"role": ["user"], "content": "Go on"}],
+ "input[1].role",
+ "list",
+ id="list_role",
+ ),
+ pytest.param(
+ [{"role": "user", "content": "Hi"}, {"role": {"name": "user"}, "content": "Go on"}],
+ "input[1].role",
+ "dict",
+ id="dict_role",
+ ),
+ pytest.param(
+ [{"role": "user", "content": "Hi"}, {"role": 7, "content": "Go on"}], "input[1].role", "int", id="int_role"
+ ),
+ pytest.param(
+ [_WEATHER_ASK, _weather_call(call_id={"id": "call_weather"})],
+ "input[1].call_id",
+ "dict",
+ id="dict_function_call_id",
+ ),
+ pytest.param(
+ [_WEATHER_ASK, _weather_call(call_id=7), {"type": "function_call_output", "call_id": "7", "output": "sunny"}],
+ "input[1].call_id",
+ "int",
+ id="int_function_call_id",
+ ),
+ pytest.param(
+ [_WEATHER_ASK, _weather_call(id=["fc_weather"])], "input[1].id", "list", id="list_function_call_item_id"
+ ),
+ pytest.param(
+ [
+ {"role": "user", "content": "Plan a Tokyo trip."},
+ {"role": "assistant", "content": "Day one, Asakusa."},
+ {"type": "compaction", "id": "cmp_litellm", "encrypted_content": json.dumps(_COMPACTION_BLOCK)},
+ {"role": ["user"], "content": "And Osaka?"},
+ ],
+ "input[3].role",
+ "list",
+ id="list_role_after_a_compaction_item",
+ ),
+ ],
+)
+def test_a_non_string_field_bridged_to_anthropic_is_a_400_naming_its_input_item(
+ history: list[dict[str, object]], field_path: str, type_name: str
+) -> None:
+ with pytest.raises(litellm.BadRequestError) as raised:
+ _bridge_to(_ANTHROPIC_MODEL, history, instructions="Be terse.")
+
+ assert raised.value.message == (
+ f"litellm.BadRequestError: Invalid type for '{field_path}': expected a string, got {type_name}"
+ )
+
+
+def test_a_non_string_role_on_an_item_that_never_reads_it_still_bridges_to_anthropic() -> None:
+ history: Final = [
+ _WEATHER_ASK,
+ _weather_call(call_id="call_weather", role=["x"]),
+ {"type": "function_call_output", "call_id": "call_weather", "output": "sunny", "role": ["x"]},
+ ]
+
+ bridged: Final = _bridge_to(_ANTHROPIC_MODEL, history)
+
+ assert [message_field(message, "role") for message in bridged["messages"]] == ["user", "assistant", "tool"]
+
+
+@pytest.mark.parametrize(
+ ("history", "field_path"),
+ [
+ pytest.param([_WEATHER_ASK, {"role": ["user"], "content": "Go on"}], "input[2].role", id="role"),
+ pytest.param(
+ [
+ _WEATHER_ASK,
+ _weather_call(call_id="call_weather"),
+ {"type": "function_call_output", "call_id": {"id": "call_weather"}, "output": "sunny"},
+ ],
+ "input[3].call_id",
+ id="tool_output_call_id",
+ ),
+ ],
+)
+def test_a_malformed_item_after_a_codex_additional_tools_item_is_named_by_its_index_in_the_request(
+ history: list[dict[str, object]], field_path: str
+) -> None:
+ tools_item: Final = {
+ "type": "additional_tools",
+ "role": "developer",
+ "tools": [{"type": "function", "name": "get_weather", "parameters": {"type": "object", "properties": {}}}],
+ }
+
+ with pytest.raises(litellm.BadRequestError) as raised:
+ litellm.responses(
+ model=_ANTHROPIC_MODEL, api_key="sk-ant-test", input=cast(ResponseInputParam, [tools_item, *history])
+ )
+
+ assert f"'{field_path}'" in raised.value.message
diff --git a/tests/unit/router_strategy/test_router_routing_groups.py b/tests/unit/router_strategy/test_router_routing_groups.py
index 6b5c052618b..5eeb46c3518 100644
--- a/tests/unit/router_strategy/test_router_routing_groups.py
+++ b/tests/unit/router_strategy/test_router_routing_groups.py
@@ -236,7 +236,7 @@ async def test_async_dispatch_uses_group_strategy_for_grouped_model():
wraps=group_selector.async_get_available_deployments,
) as latency_spy,
patch(
- "litellm.router.simple_shuffle", wraps=litellm.router.simple_shuffle
+ "litellm.router_python_core.simple_shuffle", wraps=litellm.router_python_core.simple_shuffle
) as shuffle_spy,
):
await router.async_get_available_deployment(
@@ -271,7 +271,7 @@ async def test_async_dispatch_falls_back_to_default_for_ungrouped_models():
wraps=group_selector.async_get_available_deployments,
) as latency_spy,
patch(
- "litellm.router.simple_shuffle", wraps=litellm.router.simple_shuffle
+ "litellm.router_python_core.simple_shuffle", wraps=litellm.router_python_core.simple_shuffle
) as shuffle_spy,
):
await router.async_get_available_deployment(
@@ -331,7 +331,7 @@ async def test_async_dispatch_uses_default_selector_when_constructed_with_enum()
wraps=default_selector.async_get_available_deployments,
) as latency_spy,
patch(
- "litellm.router.simple_shuffle", wraps=litellm.router.simple_shuffle
+ "litellm.router_python_core.simple_shuffle", wraps=litellm.router_python_core.simple_shuffle
) as shuffle_spy,
):
await router.async_get_available_deployment(
@@ -1376,7 +1376,7 @@ async def test_group_call_dispatches_via_group_selector():
"async_get_available_deployments",
wraps=group_selector.async_get_available_deployments,
) as latency_spy,
- patch("litellm.router.simple_shuffle", wraps=litellm.router.simple_shuffle) as shuffle_spy,
+ patch("litellm.router_python_core.simple_shuffle", wraps=litellm.router_python_core.simple_shuffle) as shuffle_spy,
):
deployment = await router.async_get_available_deployment(model="quality", request_kwargs={})
diff --git a/tests/unit/router_strategy/test_router_tag_routing.py b/tests/unit/router_strategy/test_router_tag_routing.py
index 28192704bd1..b4e29df8116 100644
--- a/tests/unit/router_strategy/test_router_tag_routing.py
+++ b/tests/unit/router_strategy/test_router_tag_routing.py
@@ -2686,7 +2686,7 @@ async def test_plain_tag_exhaustion_with_universal_default_tag_raises_by_default
router = _quality_high_cost_low_router()
with patch(
- "litellm.router.async_get_cooldown_deployments",
+ "litellm.router_python_core.async_get_cooldown_deployments",
new=AsyncMock(return_value=["quality-high-1", "quality-high-2"]),
):
with pytest.raises(Exception, match='Not allowed to access model due to tags configuration\\.') as exc_info:
@@ -2711,7 +2711,7 @@ async def test_plain_tag_exhaustion_with_universal_default_tag_falls_open_when_a
from unittest.mock import AsyncMock, patch
with patch(
- "litellm.router.async_get_cooldown_deployments",
+ "litellm.router_python_core.async_get_cooldown_deployments",
new=AsyncMock(return_value=["quality-high-1", "quality-high-2"]),
):
response = await router.acompletion(
diff --git a/tests/unit/router_utils/test_cooldown_handlers.py b/tests/unit/router_utils/test_cooldown_handlers.py
index 7deeb255e6c..71c37b5b1d5 100644
--- a/tests/unit/router_utils/test_cooldown_handlers.py
+++ b/tests/unit/router_utils/test_cooldown_handlers.py
@@ -1303,7 +1303,7 @@ class TestDeploymentCallbackOnFailureCooldownTimePrecedence:
},
}
- with patch("litellm.router.set_cooldown_deployments") as mock_set_cooldown:
+ with patch("litellm.router_python_core.set_cooldown_deployments") as mock_set_cooldown:
router.deployment_callback_on_failure(
kwargs=kwargs,
completion_response=None,
@@ -1339,7 +1339,7 @@ class TestDeploymentCallbackOnFailureCooldownTimePrecedence:
},
}
- with patch("litellm.router.set_cooldown_deployments") as mock_set_cooldown:
+ with patch("litellm.router_python_core.set_cooldown_deployments") as mock_set_cooldown:
router.deployment_callback_on_failure(
kwargs=kwargs,
completion_response=None,
diff --git a/tests/unit/router_utils/test_kubernetes_pod_discovery.py b/tests/unit/router_utils/test_kubernetes_pod_discovery.py
index 115e2a49bc5..0b772b09f6f 100644
--- a/tests/unit/router_utils/test_kubernetes_pod_discovery.py
+++ b/tests/unit/router_utils/test_kubernetes_pod_discovery.py
@@ -17,6 +17,7 @@ import respx
from openai import AsyncOpenAI, OpenAI
import litellm
+import litellm.router_python_core
from litellm.constants import (
CALLER_SESSION_ID_METADATA_KEY,
KUBERNETES_POD_ROUTING_KEY,
@@ -281,7 +282,7 @@ def _patch_router_discovery(
*discoveries: KubernetesPodDiscovery,
) -> None:
discovery_iter: Final = iter(discoveries)
- monkeypatch.setattr(litellm.router, "KubernetesPodDiscovery", lambda: next(discovery_iter))
+ monkeypatch.setattr(litellm.router_python_core, "KubernetesPodDiscovery", lambda: next(discovery_iter))
def _stub_sync_dns(monkeypatch: pytest.MonkeyPatch, *ips: str) -> None:
diff --git a/tests/unit/test_conftest_isolation.py b/tests/unit/test_conftest_isolation.py
index 92457119a82..3fdc2d65da1 100644
--- a/tests/unit/test_conftest_isolation.py
+++ b/tests/unit/test_conftest_isolation.py
@@ -117,12 +117,12 @@ def test_live_router_membership_is_scoped_to_this_test():
}
]
)
- assert _CanaryRouterHolder.router in litellm_router_module._live_routers
+ assert _CanaryRouterHolder.router._core in litellm_router_module._live_routers
def test_live_router_membership_was_rolled_back():
assert _CanaryRouterHolder.router is not None
- assert _CanaryRouterHolder.router not in litellm_router_module._live_routers
+ assert _CanaryRouterHolder.router._core not in litellm_router_module._live_routers
def test_aws_cache_reset_sees_new_handlers_and_replaced_caches(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -175,7 +175,7 @@ def registered_collection_models() -> Iterator[None]:
yield
litellm_utils_module._runtime_registered_model_cost.clear()
litellm_utils_module._runtime_registered_model_cost.update(original_registrations)
- litellm_router_module._live_routers.discard(collection_router)
+ litellm_router_module._live_routers.discard(collection_router._core)
litellm_router_module._live_routers.update(original_routers)
diff --git a/tests/unit/test_router/core/__init__.py b/tests/unit/test_router/core/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/unit/test_router/core/conftest.py b/tests/unit/test_router/core/conftest.py
new file mode 100644
index 00000000000..dffa938c441
--- /dev/null
+++ b/tests/unit/test_router/core/conftest.py
@@ -0,0 +1,26 @@
+from collections.abc import Callable
+
+import pytest
+
+from litellm.router_core import RouterCore
+from tests.unit.test_router.core.scripted_host import CORE_BUILDERS, CoreDeclined, ScriptedHost
+
+
+@pytest.fixture
+def host() -> ScriptedHost:
+ return ScriptedHost()
+
+
+@pytest.fixture(params=sorted(CORE_BUILDERS))
+def make_core(request: pytest.FixtureRequest, host: ScriptedHost) -> Callable[..., RouterCore]:
+ """``make_core(**router_arguments)``: the core under test, on the ``host`` fixture. Every test that
+ uses it runs once per core in ``CORE_BUILDERS`` (``-k python`` or ``-k rust`` picks one)."""
+ builder = CORE_BUILDERS[request.param]
+
+ def build(**arguments: object) -> RouterCore: # kwargs-ok: the router's constructor arguments
+ try:
+ return builder(host, **arguments)
+ except CoreDeclined as declined:
+ pytest.skip(f"{request.param} core declines this configuration: {declined}")
+
+ return build
diff --git a/tests/unit/test_router/core/scripted_host.py b/tests/unit/test_router/core/scripted_host.py
new file mode 100644
index 00000000000..af28160b620
--- /dev/null
+++ b/tests/unit/test_router/core/scripted_host.py
@@ -0,0 +1,487 @@
+"""
+A host for driving ``PythonCore`` in isolation.
+
+``PythonCore`` reaches outside itself only through its host (``litellm/router_python_host.py``):
+inference calls, backoff sleeps, the process settings, callback registration and the process-wide
+cache. ``ScriptedHost`` serves all of those from the test. Inference ops answer from a per-model
+script and record every call; sleeps are recorded and return at once; the process settings are a
+fixed ``RouterDynamicConfig`` the test can replace; callbacks live in the host's own lists, and an
+answered call runs the success or failure callbacks the core registered, as litellm's logging does.
+Ops that are not inference (``get_model_info``, ``should_retry``, ...) are litellm's own functions.
+"""
+
+import dataclasses
+import itertools
+from collections.abc import Awaitable, Callable, Iterable, Iterator, Mapping
+from types import MethodType
+from typing import Final
+
+import litellm
+from litellm.integrations.custom_logger import CustomLogger
+from litellm.litellm_core_utils.logging_callback_manager import LoggingCallbackManager
+from litellm.router_config import RouterDynamicConfig, build_router_config
+from litellm.router_core import RouterCore
+from litellm.router_python_core import PythonCore
+from litellm.router_python_host import CallbackList, Endpoint, PythonHost
+from litellm.types.utils import EmbeddingResponse, ModelResponse
+
+INFERENCE_OPS: Final = frozenset(
+ {
+ "acompletion",
+ "completion",
+ "atext_completion",
+ "text_completion",
+ "aembedding",
+ "embedding",
+ "aimage_generation",
+ "image_generation",
+ "aspeech",
+ "atranscription",
+ "arerank",
+ "rerank",
+ "amoderation",
+ "aresponses",
+ "responses",
+ "anthropic_messages",
+ "agenerate_content",
+ "aadapter_generate_content",
+ "aocr",
+ "asearch",
+ "avideo_generation",
+ "acreate_batch",
+ "acreate_file",
+ "afile_content",
+ "afile_delete",
+ "acreate_fine_tuning_job",
+ "aget_responses",
+ "adelete_responses",
+ "acancel_responses",
+ "alist_input_items",
+ }
+)
+
+Outcome = object
+"""What a scripted call answers: a ``BaseException`` instance is raised, a callable is called with
+the request's keyword arguments (and its result used in turn), anything else is returned as is.
+``None`` builds a default response for the op."""
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class Call:
+ op: str
+ model: str
+ kwargs: Mapping[str, object]
+
+ @property
+ def deployment_id(self) -> str | None:
+ model_info: Final = self.kwargs.get("model_info")
+ return str(model_info["id"]) if isinstance(model_info, Mapping) and "id" in model_info else None
+
+ @property
+ def model_group(self) -> str | None:
+ metadata: Final = self.kwargs.get("metadata") or self.kwargs.get("litellm_metadata")
+ return metadata.get("model_group") if isinstance(metadata, Mapping) else None
+
+
+def completion_response(content: str = "ok", total_tokens: int = 10, model: str = "gpt-4o") -> ModelResponse:
+ return ModelResponse(
+ model=model,
+ choices=[{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": content}}],
+ usage={
+ "prompt_tokens": total_tokens // 2,
+ "completion_tokens": total_tokens - total_tokens // 2,
+ "total_tokens": total_tokens,
+ },
+ )
+
+
+def embedding_response(model: str = "text-embedding-3-small") -> EmbeddingResponse:
+ return EmbeddingResponse(
+ model=model,
+ data=[{"object": "embedding", "index": 0, "embedding": [0.1, 0.2]}],
+ usage={"prompt_tokens": 2, "total_tokens": 2},
+ )
+
+
+def _fresh_error(template: BaseException) -> BaseException:
+ """A copy of ``template`` with the same attributes (``copy.copy`` rebuilds litellm errors from
+ their args and drops ``status_code`` and the rest)."""
+ error: Final = BaseException.__new__(type(template))
+ error.args = template.args
+ error.__dict__.update(template.__dict__)
+ return error
+
+
+def _default_response(op: str, model: str) -> object:
+ if op in {"aembedding", "embedding"}:
+ return embedding_response(model)
+ return completion_response(model=model)
+
+
+def rate_limit(model: str = "gpt-4o", message: str = "slow down") -> litellm.RateLimitError:
+ return litellm.RateLimitError(message=message, llm_provider="openai", model=model)
+
+
+def bad_request(model: str = "gpt-4o", message: str = "bad input") -> litellm.BadRequestError:
+ return litellm.BadRequestError(message=message, llm_provider="openai", model=model)
+
+
+def context_window(model: str = "gpt-4o") -> litellm.ContextWindowExceededError:
+ return litellm.ContextWindowExceededError(message="too long", llm_provider="openai", model=model)
+
+
+def content_policy(model: str = "gpt-4o") -> litellm.ContentPolicyViolationError:
+ return litellm.ContentPolicyViolationError(message="blocked", llm_provider="openai", model=model)
+
+
+def server_error(model: str = "gpt-4o") -> litellm.InternalServerError:
+ return litellm.InternalServerError(message="boom", llm_provider="openai", model=model)
+
+
+def timeout(model: str = "gpt-4o") -> litellm.Timeout:
+ return litellm.Timeout(message="timed out", llm_provider="openai", model=model)
+
+
+def auth_error(model: str = "gpt-4o") -> litellm.AuthenticationError:
+ return litellm.AuthenticationError(message="bad key", llm_provider="openai", model=model)
+
+
+def dynamic_config(**changes: object) -> RouterDynamicConfig: # kwargs-ok: RouterDynamicConfig fields
+ """Process settings as a fresh litellm process has them, with no callbacks, cache or fallbacks."""
+ base: Final = RouterDynamicConfig(
+ version=1,
+ num_retries=None,
+ max_fallbacks=None,
+ router_max_fallbacks=5,
+ allowed_fails=3,
+ request_timeout=6000.0,
+ fallbacks=None,
+ default_fallbacks=None,
+ context_window_fallbacks=None,
+ content_policy_fallbacks=None,
+ drop_params=None,
+ provider_list=litellm.provider_list,
+ expose_router_debug_in_errors=False,
+ redact_user_api_key_info=None,
+ model_alias_map={},
+ model_cost=litellm.model_cost,
+ known_custom_logger_compatible_callbacks=litellm._known_custom_logger_compatible_callbacks,
+ exception_types=litellm.LITELLM_EXCEPTION_TYPES,
+ callbacks=[],
+ success_callback=[],
+ failure_callback=[],
+ input_callback=[],
+ callback_manager=litellm.logging_callback_manager,
+ cache=None,
+ )
+ return dataclasses.replace(base, **changes)
+
+
+class HostCallbacks:
+ """The callback registration the core asks its host for, over the host's own lists."""
+
+ def __init__(self, host: "ScriptedHost") -> None:
+ self._host: Final = host
+ self._keys: Final = LoggingCallbackManager()
+
+ def _add(self, name: CallbackList, callback: object) -> None:
+ target: Final = self._host.lists[name]
+ if callback not in target:
+ target.append(callback)
+
+ def add_litellm_callback(self, callback: object) -> None:
+ self._add_to("callbacks", callback)
+
+ def _add_to(self, name: str, callback: object) -> None:
+ target: Final = self._host.lists.setdefault(name, [])
+ if callback not in target:
+ target.append(callback)
+
+ def add_litellm_success_callback(self, callback: object) -> None:
+ self._add("success_callback", callback)
+
+ def add_litellm_async_success_callback(self, callback: object) -> None:
+ self._add("_async_success_callback", callback)
+
+ def add_litellm_failure_callback(self, callback: object) -> None:
+ self._add("failure_callback", callback)
+
+ def add_litellm_async_failure_callback(self, callback: object) -> None:
+ self._add("_async_failure_callback", callback)
+
+ def add_litellm_input_callback(self, callback: object) -> None:
+ self._add("input_callback", callback)
+
+ def get_custom_logger_key(self, custom_logger: CustomLogger) -> str:
+ return self._keys.get_custom_logger_key(custom_logger)
+
+ def with_custom_logger_keys(
+ self, candidates: Iterable[CustomLogger], keys: frozenset[str]
+ ) -> tuple[CustomLogger, ...]:
+ return self._keys.with_custom_logger_keys(candidates, keys)
+
+ def without_custom_logger_keys(self, callbacks: Iterable[object], keys: frozenset[str]) -> Iterator[object]:
+ return self._keys.without_custom_logger_keys(callbacks, keys) # pyright: ignore[reportArgumentType] # any callback list
+
+ def get_all_callbacks(self) -> list[object]: # mutable-ok: matches LoggingCallbackManager
+ return [callback for callbacks in self._host.lists.values() for callback in callbacks]
+
+ def get_custom_loggers_for_type(
+ self, callback_type: type[CustomLogger]
+ ) -> list[CustomLogger]: # mutable-ok: matches LoggingCallbackManager
+ found: Final[list[CustomLogger]] = []
+ for callback in self.get_all_callbacks():
+ if isinstance(callback, callback_type) and callback not in found:
+ found.append(callback)
+ return found
+
+ def drop_callbacks(self, callbacks: Iterable[object], method_owners: Iterable[object] = ()) -> frozenset[str]:
+ dropped_ids: Final = frozenset(map(id, callbacks))
+ owner_ids: Final = frozenset(map(id, method_owners))
+
+ def keep(callback: object) -> bool:
+ if id(callback) in dropped_ids:
+ return False
+ return not (isinstance(callback, MethodType) and id(callback.__self__) in owner_ids)
+
+ vacated: Final = frozenset(
+ self.get_custom_logger_key(callback)
+ for callback in self._host.lists.get("callbacks", [])
+ if isinstance(callback, CustomLogger) and id(callback) in dropped_ids
+ )
+ for name, callbacks_in_list in self._host.lists.items():
+ self._host.lists[name] = [callback for callback in callbacks_in_list if keep(callback)]
+ return vacated
+
+
+class ScriptedHost(PythonHost):
+ """
+ ``script`` maps a deployment's ``litellm_params.model`` (or ``"*"`` for any) to the outcomes its
+ calls answer in turn; the last outcome repeats. A model with no script answers a default response.
+ """
+
+ __slots__ = ("_dynamic", "_scripts", "_turns", "cache", "calls", "lists", "raised", "sleeps", "suppressed")
+
+ def __init__(
+ self, script: Mapping[str, Iterable[Outcome]] | None = None, **dynamic: object
+ ) -> None: # kwargs-ok: RouterDynamicConfig fields
+ self._scripts: dict[str, tuple[Outcome, ...]] = {k: tuple(v) for k, v in (script or {}).items()}
+ self._turns: dict[str, Iterator[int]] = {}
+ self._dynamic: RouterDynamicConfig = dynamic_config(**dynamic)
+ self.calls: Final[list[Call]] = []
+ self.sleeps: Final[list[float]] = []
+ # The errors the scripted calls raised, in order: each is a fresh copy of its scripted template.
+ self.raised: Final[list[BaseException]] = []
+ self.lists: Final[dict[str, list[object]]] = {
+ "callbacks": [],
+ "success_callback": [],
+ "_async_success_callback": [],
+ "failure_callback": [],
+ "_async_failure_callback": [],
+ "input_callback": [],
+ }
+ self.cache: object = None
+ self.suppressed = False
+
+ # --- what the test controls -------------------------------------------------------------
+
+ def answer(self, model: str, *outcomes: Outcome) -> None:
+ """Script the calls to ``model`` from now on."""
+ self._scripts[model] = outcomes
+ self._turns.pop(model, None)
+
+ def set_dynamic(self, **changes: object) -> None: # kwargs-ok: RouterDynamicConfig fields
+ self._dynamic = dataclasses.replace(self._dynamic, **changes, version=self._dynamic.version + 1)
+
+ def calls_to(self, op: str | None = None) -> list[Call]: # mutable-ok: a fresh list
+ return [call for call in self.calls if op is None or call.op == op]
+
+ def deployments_called(self, op: str | None = None) -> list[str | None]: # mutable-ok: a fresh list
+ return [call.deployment_id for call in self.calls_to(op)]
+
+ # --- the host operations ----------------------------------------------------------------
+
+ def invoke(self, op: str) -> Endpoint:
+ if op not in INFERENCE_OPS:
+ return super().invoke(op)
+ if op.startswith("a") or op in {"anthropic_messages", "_arealtime"}:
+
+ async def endpoint(*args: object, **kwargs: object) -> object: # kwargs-ok: the endpoint's request
+ return await self._answer_async(op, args, kwargs)
+
+ return endpoint
+
+ def sync_endpoint(*args: object, **kwargs: object) -> object: # kwargs-ok: the endpoint's request
+ return self._answer_sync(op, args, kwargs)
+
+ return sync_endpoint
+
+ def sleep(self, seconds: float) -> Awaitable[None]:
+ self.sleeps.append(seconds)
+
+ async def done() -> None:
+ return None
+
+ return done()
+
+ def dynamic(self) -> RouterDynamicConfig:
+ return self._dynamic
+
+ @property
+ def callbacks(self) -> HostCallbacks: # pyright: ignore[reportIncompatibleVariableOverride] # the core uses the same methods
+ return HostCallbacks(self)
+
+ def callback_list(self, name: CallbackList) -> list[object]: # mutable-ok: the host's own list
+ return self.lists.setdefault(name, [])
+
+ def set_callback_list(self, name: CallbackList, value: list[object]) -> None: # mutable-ok: assigned as given
+ self.lists[name] = value
+
+ def global_cache(self) -> object: # pyright: ignore[reportIncompatibleMethodOverride] # any cache object
+ return self.cache
+
+ def set_global_cache(self, cache: object) -> None: # pyright: ignore[reportIncompatibleMethodOverride] # any cache object
+ self.cache = cache
+
+ def suppress_debug_info(self) -> None:
+ self.suppressed = True
+
+ # --- answering ------------------------------------------------------------------------
+
+ def _outcome(self, op: str, args: tuple[object, ...], kwargs: Mapping[str, object]) -> tuple[Call, object]:
+ model: Final = str(kwargs.get("model") or (args[0] if args else ""))
+ call: Final = Call(op=op, model=model, kwargs=dict(kwargs))
+ self.calls.append(call)
+ key: Final = model if model in self._scripts else "*" if "*" in self._scripts else None
+ if key is None:
+ return call, None
+ outcomes: Final = self._scripts[key]
+ turn: Final = next(self._turns.setdefault(key, itertools.count()))
+ outcome: object = outcomes[min(turn, len(outcomes) - 1)] if outcomes else None
+ while callable(outcome) and not isinstance(outcome, BaseException | type):
+ outcome = outcome(**kwargs) # rebind-ok: a callable outcome answers with what it returns
+ if isinstance(outcome, BaseException):
+ # A fresh error per call: the core stamps attributes (num_retries, ...) on the one it raises.
+ outcome = _fresh_error(outcome) # rebind-ok: the scripted error is a template
+ self.raised.append(outcome)
+ return call, outcome
+
+ def _logging_kwargs(
+ self, call: Call, error: BaseException | None, response: object
+ ) -> dict[str, object]: # mutable-ok: litellm's logging kwargs
+ kwargs: Final = call.kwargs
+ usage: Final = getattr(response, "usage", None)
+ metadata: Final = kwargs.get("metadata") or kwargs.get("litellm_metadata") or {}
+ model_info: Final = kwargs.get("model_info") or {}
+ return {
+ "call_type": call.op,
+ "model": call.model,
+ "exception": error,
+ "litellm_params": {
+ "metadata": metadata,
+ "model_info": model_info,
+ "api_base": kwargs.get("api_base"),
+ **({"cooldown_time": kwargs["cooldown_time"]} if "cooldown_time" in kwargs else {}),
+ },
+ "standard_logging_object": {
+ "total_tokens": getattr(usage, "total_tokens", 0) or 0,
+ "model_group": metadata.get("model_group"),
+ "model_id": model_info.get("id"),
+ "hidden_params": {"litellm_model_name": call.model},
+ },
+ }
+
+ def _finish(self, op: str, call: Call, outcome: object) -> tuple[BaseException | None, object]:
+ if isinstance(outcome, type) and issubclass(outcome, BaseException):
+ outcome = outcome("scripted failure") # rebind-ok: an exception class raises an instance
+ if isinstance(outcome, BaseException):
+ return outcome, None
+ return None, _default_response(op, call.model) if outcome is None else outcome
+
+ async def _answer_async(self, op: str, args: tuple[object, ...], kwargs: Mapping[str, object]) -> object:
+ call, outcome = self._outcome(op, args, kwargs)
+ error, response = self._finish(op, call, outcome)
+ logged: Final = self._logging_kwargs(call, error, response)
+ if error is not None:
+ for callback in list(self.lists.get("failure_callback", ())):
+ if callable(callback):
+ callback(logged, None, 0.0, 0.0)
+ for callback in list(self.lists.get("_async_failure_callback", ())):
+ if callable(callback):
+ await callback(logged, None, 0.0, 0.0)
+ raise error
+ for callback in list(self.lists.get("_async_success_callback", ())):
+ if callable(callback):
+ await callback(logged, response, 0.0, 0.0)
+ return response
+
+ def _answer_sync(self, op: str, args: tuple[object, ...], kwargs: Mapping[str, object]) -> object:
+ call, outcome = self._outcome(op, args, kwargs)
+ error, response = self._finish(op, call, outcome)
+ logged: Final = self._logging_kwargs(call, error, response)
+ if error is not None:
+ for callback in list(self.lists.get("failure_callback", ())):
+ if callable(callback):
+ callback(logged, None, 0.0, 0.0)
+ raise error
+ for callback in list(self.lists.get("success_callback", ())):
+ if callable(callback):
+ callback(logged, response, 0.0, 0.0)
+ return response
+
+
+def deployment(
+ model_name: str,
+ deployment_id: str,
+ model: str | None = None,
+ **params: object, # kwargs-ok: extra litellm_params
+) -> dict[str, object]: # mutable-ok: a model_list entry
+ """One ``model_list`` entry; ``model`` defaults to ``openai/`` so each deployment
+ answers its own script."""
+ model_info: Final = params.pop("model_info", None)
+ return {
+ "model_name": model_name,
+ "litellm_params": {"model": model or f"openai/{deployment_id}", "api_key": "sk-test", **params},
+ "model_info": {"id": deployment_id, **(model_info if isinstance(model_info, dict) else {})},
+ }
+
+
+def python_core(host: ScriptedHost, **arguments: object) -> PythonCore: # kwargs-ok: the router's constructor arguments
+ """A ``PythonCore`` built the way ``Router`` builds one, on ``host``."""
+ config: Final = build_router_config(arguments)
+ return PythonCore(**config.arguments, _host=host) # pyright: ignore[reportArgumentType] # the same arguments Router binds
+
+
+CoreBuilder = Callable[..., RouterCore]
+"""``builder(host, **router_arguments)``: a router core on ``host``."""
+
+CORE_BUILDERS: Final[dict[str, CoreBuilder]] = {"python": python_core}
+"""Every core the suite runs on. Another core adds its builder here; a builder that cannot take a
+configuration raises ``CoreDeclined``, and the test is skipped for that core."""
+
+
+class CoreDeclined(Exception):
+ """The core does not serve this configuration (the router would stay on the Python core)."""
+
+
+__all__ = [
+ "Call",
+ "HostCallbacks",
+ "INFERENCE_OPS",
+ "CORE_BUILDERS",
+ "CoreBuilder",
+ "CoreDeclined",
+ "ScriptedHost",
+ "auth_error",
+ "bad_request",
+ "completion_response",
+ "content_policy",
+ "context_window",
+ "deployment",
+ "dynamic_config",
+ "embedding_response",
+ "python_core",
+ "rate_limit",
+ "server_error",
+ "timeout",
+]
diff --git a/tests/unit/test_router/core/test_endpoints.py b/tests/unit/test_router/core/test_endpoints.py
new file mode 100644
index 00000000000..b2ad602c4c6
--- /dev/null
+++ b/tests/unit/test_router/core/test_endpoints.py
@@ -0,0 +1,446 @@
+"""
+What a router core sends to its host for each endpoint, and what it hands back.
+
+Every test builds its core through ``make_core`` on the ``host`` fixture, so it runs once per core.
+"""
+
+import datetime
+import io
+from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
+from typing import Final
+
+import pytest
+
+import litellm
+from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
+from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
+from litellm.router_core import RouterCore
+from litellm.types.utils import EmbeddingResponse, ModelResponse, ModelResponseStream
+from tests.unit.test_router.core.scripted_host import (
+ ScriptedHost,
+ bad_request,
+ completion_response,
+ deployment,
+ embedding_response,
+)
+
+MESSAGES: Final = [{"role": "user", "content": "hi"}]
+MakeCore = Callable[..., RouterCore]
+
+EndpointCall = Callable[[RouterCore], object]
+
+ENDPOINTS: Final[dict[str, tuple[str, EndpointCall]]] = {
+ "acompletion": ("acompletion", lambda core: core.acompletion(model="g", messages=MESSAGES)),
+ "completion": ("completion", lambda core: core.completion(model="g", messages=MESSAGES)),
+ "aembedding": ("aembedding", lambda core: core.aembedding(model="g", input=["a"])),
+ "embedding": ("embedding", lambda core: core.embedding(model="g", input=["a"])),
+ "atext_completion": ("atext_completion", lambda core: core.atext_completion(model="g", prompt="hi")),
+ "aimage_generation": ("aimage_generation", lambda core: core.aimage_generation(model="g", prompt="a cat")),
+ "image_generation": ("image_generation", lambda core: core.image_generation(model="g", prompt="a cat")),
+ "arerank": ("arerank", lambda core: core.arerank(model="g", query="q", documents=["a"])),
+ "aspeech": ("aspeech", lambda core: core.aspeech(model="g", input="hi", voice="alloy")),
+ "atranscription": ("atranscription", lambda core: core.atranscription(model="g", file=io.BytesIO(b"x"))),
+ "aresponses": ("aresponses", lambda core: core.aresponses(model="g", input="hi")),
+ "aanthropic_messages": (
+ "anthropic_messages",
+ lambda core: core.aanthropic_messages(model="g", messages=MESSAGES, max_tokens=5),
+ ),
+ "call-acompletion": ("acompletion", lambda core: core.call("acompletion", {"model": "g", "messages": MESSAGES})),
+ "call-completion": ("completion", lambda core: core.call("completion", {"model": "g", "messages": MESSAGES})),
+}
+SYNC_ENDPOINTS: Final = frozenset({"completion", "embedding", "image_generation"})
+LITELLM_METADATA_ENDPOINTS: Final = frozenset({"aresponses", "aanthropic_messages"})
+EMBEDDING_ENDPOINTS: Final = frozenset({"aembedding", "embedding"})
+
+
+async def _invoke(core: RouterCore, endpoint: str) -> object:
+ result: Final = ENDPOINTS[endpoint][1](core)
+ if endpoint in SYNC_ENDPOINTS:
+ return result
+ return await result # pyright: ignore[reportGeneralTypeIssues] # the async endpoints return awaitables
+
+
+def _one_group(
+ make_core: MakeCore, **litellm_params: object
+) -> RouterCore: # kwargs-ok: the deployment's litellm_params
+ return make_core(model_list=[deployment("g", "d1", **litellm_params)])
+
+
+def _metadata(kwargs: Mapping[str, object], endpoint: str) -> Mapping[str, object]:
+ bucket: Final = kwargs["litellm_metadata" if endpoint in LITELLM_METADATA_ENDPOINTS else "metadata"]
+ assert isinstance(bucket, Mapping)
+ return bucket
+
+
+@pytest.mark.parametrize("endpoint", sorted(ENDPOINTS))
+async def test_each_endpoint_sends_the_deployment_litellm_params_to_the_host(
+ make_core: MakeCore, host: ScriptedHost, endpoint: str
+):
+ core: Final = _one_group(make_core, api_base="https://d1.example", temperature=0.3, seed=7)
+ await _invoke(core, endpoint)
+ sent: Final = host.calls[0].kwargs
+ assert (sent["model"], sent["api_key"], sent["api_base"], sent["temperature"], sent["seed"]) == (
+ "openai/d1",
+ "sk-test",
+ "https://d1.example",
+ 0.3,
+ 7,
+ )
+
+
+@pytest.mark.parametrize("endpoint", sorted(ENDPOINTS))
+async def test_each_endpoint_invokes_its_host_op_once_with_routing_metadata(
+ make_core: MakeCore, host: ScriptedHost, endpoint: str
+):
+ core: Final = _one_group(make_core, api_base="https://d1.example")
+ await _invoke(core, endpoint)
+ assert [call.op for call in host.calls] == [ENDPOINTS[endpoint][0]]
+ call: Final = host.calls[0]
+ metadata: Final = _metadata(call.kwargs, endpoint)
+ assert (call.model_group, call.deployment_id) == ("g", "d1")
+ assert (metadata["model_group"], metadata["deployment"], metadata["api_base"]) == (
+ "g",
+ "openai/d1",
+ "https://d1.example",
+ )
+ assert metadata["model_info"]["id"] == "d1" # pyright: ignore[reportIndexIssue] # metadata values are untyped
+
+
+@pytest.mark.parametrize("endpoint", sorted(ENDPOINTS))
+async def test_each_endpoint_returns_the_host_response_object_with_routing_hidden_params(
+ make_core: MakeCore, host: ScriptedHost, endpoint: str
+):
+ answer: Final[ModelResponse | EmbeddingResponse] = (
+ embedding_response() if endpoint in EMBEDDING_ENDPOINTS else completion_response("scripted")
+ )
+ host.answer("openai/d1", answer)
+ core: Final = _one_group(make_core)
+ response: Final = await _invoke(core, endpoint)
+ assert response is answer
+ assert response._hidden_params["model_id"] == "d1"
+ assert response._hidden_params["additional_headers"]["x-litellm-model-group"] == "g"
+
+
+@pytest.mark.parametrize("endpoint", sorted(SYNC_ENDPOINTS | {"call-completion"}))
+async def test_sync_endpoints_return_the_response_not_an_awaitable(
+ make_core: MakeCore, host: ScriptedHost, endpoint: str
+):
+ answer: Final = embedding_response() if endpoint == "embedding" else completion_response("sync")
+ host.answer("openai/d1", answer)
+ core: Final = _one_group(make_core)
+ if endpoint == "call-completion":
+ assert await core.call("completion", {"model": "g", "messages": MESSAGES}) is answer
+ else:
+ assert ENDPOINTS[endpoint][1](core) is answer
+
+
+@pytest.mark.parametrize(
+ "endpoint, request_body",
+ [("acompletion", {"messages": MESSAGES}), ("completion", {"messages": MESSAGES})],
+)
+async def test_router_only_include_fallback_errors_is_not_sent_to_the_host(
+ make_core: MakeCore, host: ScriptedHost, endpoint: str, request_body: dict[str, object]
+):
+ core: Final = _one_group(make_core)
+ await core.call(endpoint, {"model": "g", "include_fallback_errors": True, **request_body})
+ assert host.calls[0].op == endpoint
+ assert "include_fallback_errors" not in host.calls[0].kwargs
+
+
+@pytest.mark.parametrize("router_only_param", ["rpm", "tpm", "max_parallel_requests"])
+async def test_deployment_limit_params_are_forwarded_with_the_rest_of_litellm_params(
+ make_core: MakeCore, host: ScriptedHost, router_only_param: str
+):
+ core: Final = _one_group(make_core, **{router_only_param: 50})
+ await core.acompletion(model="g", messages=MESSAGES)
+ assert host.calls[0].kwargs[router_only_param] == 50
+
+
+@pytest.mark.parametrize("endpoint", ["acompletion", "aembedding", "completion", "embedding"])
+async def test_request_metadata_reaches_the_host_beside_the_routing_metadata(
+ make_core: MakeCore, host: ScriptedHost, endpoint: str
+):
+ core: Final = _one_group(make_core)
+ request: Final[dict[str, object]] = {"messages": MESSAGES} if "completion" in endpoint else {"input": ["a"]}
+ await core.call(endpoint, {"model": "g", "metadata": {"trace": "t-1"}, **request})
+ metadata: Final = _metadata(host.calls[0].kwargs, endpoint)
+ assert (metadata["trace"], metadata["model_group"]) == ("t-1", "g")
+
+
+async def test_aresponses_sends_request_metadata_as_metadata_and_routing_metadata_as_litellm_metadata(
+ make_core: MakeCore, host: ScriptedHost
+):
+ core: Final = _one_group(make_core)
+ await core.aresponses(model="g", input="hi", metadata={"trace": "t-1"})
+ sent: Final = host.calls[0].kwargs
+ assert sent["metadata"] == {"trace": "t-1"}
+ assert sent["litellm_metadata"]["model_group"] == "g" # pyright: ignore[reportIndexIssue] # untyped kwargs
+
+
+@pytest.mark.parametrize(
+ "router_arguments, deployment_params, expected_timeout",
+ [
+ ({}, {}, 6000.0),
+ ({"timeout": 42}, {}, 42),
+ ({"timeout": 42}, {"timeout": 11}, 11.0),
+ ({}, {"timeout": 11}, 11.0),
+ ],
+)
+async def test_acompletion_timeout_comes_from_deployment_then_router_then_process_setting(
+ make_core: MakeCore,
+ host: ScriptedHost,
+ router_arguments: dict[str, object],
+ deployment_params: dict[str, object],
+ expected_timeout: float,
+):
+ core: Final = make_core(model_list=[deployment("g", "d1", **deployment_params)], **router_arguments)
+ await core.acompletion(model="g", messages=MESSAGES)
+ assert host.calls[0].kwargs["timeout"] == expected_timeout
+
+
+@pytest.mark.parametrize("endpoint", ["acompletion", "aembedding", "aresponses", "aanthropic_messages"])
+async def test_request_timeout_overrides_router_and_deployment_timeouts(
+ make_core: MakeCore, host: ScriptedHost, endpoint: str
+):
+ core: Final = make_core(model_list=[deployment("g", "d1", timeout=11)], timeout=42)
+ request: Final[dict[str, object]] = (
+ {"input": ["a"]}
+ if endpoint == "aembedding"
+ else {"input": "hi"}
+ if endpoint == "aresponses"
+ else {"messages": MESSAGES, "max_tokens": 5}
+ )
+ await core.call(endpoint, {"model": "g", "timeout": 7, **request})
+ assert host.calls[0].kwargs["timeout"] == 7
+
+
+async def test_aresponses_timeout_falls_back_to_router_timeout(make_core: MakeCore, host: ScriptedHost):
+ core: Final = make_core(model_list=[deployment("g", "d1")], timeout=42)
+ await core.aresponses(model="g", input="hi")
+ assert host.calls[0].kwargs["timeout"] == 42
+
+
+@pytest.mark.parametrize(
+ "request_stream_timeout, expected_stream_timeout",
+ [(None, 3.0), (2, 2)],
+)
+async def test_streaming_acompletion_sends_deployment_or_request_stream_timeout(
+ make_core: MakeCore, host: ScriptedHost, request_stream_timeout: int | None, expected_stream_timeout: float
+):
+ core: Final = _one_group(make_core, timeout=11, stream_timeout=3)
+ extra: Final = {} if request_stream_timeout is None else {"stream_timeout": request_stream_timeout}
+ await core.acompletion(model="g", messages=MESSAGES, stream=True, **extra)
+ sent: Final = host.calls[0].kwargs
+ assert (sent["stream"], sent["timeout"], sent["stream_timeout"]) == (True, 11.0, expected_stream_timeout)
+
+
+@pytest.mark.parametrize(
+ "request_params, expected",
+ [
+ ({}, ("u1", 0.9)),
+ ({"temperature": 0.1}, ("u1", 0.1)),
+ ({"user": "u2"}, ("u2", 0.9)),
+ ],
+)
+async def test_default_litellm_params_fill_what_the_request_leaves_unset(
+ make_core: MakeCore, host: ScriptedHost, request_params: dict[str, object], expected: tuple[str, float]
+):
+ core: Final = make_core(
+ model_list=[deployment("g", "d1")], default_litellm_params={"user": "u1", "temperature": 0.9}
+ )
+ await core.acompletion(model="g", messages=MESSAGES, **request_params)
+ sent: Final = host.calls[0].kwargs
+ assert (sent["user"], sent["temperature"]) == expected
+
+
+@pytest.mark.parametrize(
+ "request_credentials, expected_api_base",
+ [
+ ({"api_key": "sk-client"}, "https://d1.example"),
+ ({"api_key": "sk-client", "api_base": "https://client.example"}, "https://client.example"),
+ ],
+)
+async def test_request_level_api_key_replaces_the_deployment_key_on_the_host_call(
+ make_core: MakeCore, host: ScriptedHost, request_credentials: dict[str, str], expected_api_base: str
+):
+ core: Final = _one_group(make_core, api_base="https://d1.example")
+ await core.acompletion(model="g", messages=MESSAGES, **request_credentials)
+ call: Final = host.calls[0]
+ assert (call.kwargs["api_key"], call.kwargs["api_base"], call.model_group) == (
+ "sk-client",
+ expected_api_base,
+ "g",
+ )
+
+
+async def test_construction_registers_one_success_and_one_failure_callback_per_mode(
+ make_core: MakeCore, host: ScriptedHost
+):
+ _one_group(make_core)
+ registered: Final = {name: len(callbacks) for name, callbacks in host.lists.items()}
+ assert registered == {
+ "callbacks": 0,
+ "success_callback": 1,
+ "_async_success_callback": 1,
+ "failure_callback": 1,
+ "_async_failure_callback": 1,
+ "input_callback": 0,
+ }
+ assert all(callable(callbacks[0]) for callbacks in host.lists.values() if callbacks)
+
+
+async def test_retire_removes_the_callbacks_the_core_registered(make_core: MakeCore, host: ScriptedHost):
+ core: Final = _one_group(make_core)
+ core.retire()
+ assert {name: callbacks for name, callbacks in host.lists.items() if callbacks} == {}
+
+
+async def test_sync_completion_counts_its_tokens_and_request_in_group_usage(make_core: MakeCore, host: ScriptedHost):
+ core: Final = _one_group(make_core, tpm=100, rpm=5)
+ assert await core.health().usage("g") == (None, None)
+ core.completion(model="g", messages=MESSAGES)
+ assert await core.health().usage("g") == (10, 1)
+ assert await core.health().usage("g", kind="remaining") == {
+ "x-ratelimit-remaining-tokens": 90,
+ "x-ratelimit-limit-tokens": 100,
+ "x-ratelimit-remaining-requests": 4,
+ "x-ratelimit-limit-requests": 5,
+ }
+
+
+class DeferredLoggingHost(ScriptedHost):
+ """``ScriptedHost`` that, like litellm, runs async success callbacks after the endpoint returns."""
+
+ __slots__ = ("pending",)
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.pending: list[Coroutine[object, object, None]] = []
+
+ async def _answer_async(self, op: str, args: tuple[object, ...], kwargs: Mapping[str, object]) -> object:
+ callbacks: Final = self.lists["_async_success_callback"]
+ self.lists["_async_success_callback"] = []
+ try:
+ response: Final = await super()._answer_async(op, args, kwargs)
+ finally:
+ self.lists["_async_success_callback"] = callbacks
+ call: Final = self.calls[-1]
+ logged: Final = self._logging_kwargs(call, None, response)
+ self.pending.extend(callback(logged, response, 0.0, 0.0) for callback in callbacks if callable(callback))
+ return response
+
+ async def drain(self) -> None:
+ while self.pending:
+ await self.pending.pop(0)
+
+
+class TestUsageWhenTheHostLogsAfterTheResponse:
+ """litellm runs async success callbacks after the endpoint has returned; this host does the same."""
+
+ @pytest.fixture
+ def host(self) -> DeferredLoggingHost:
+ return DeferredLoggingHost()
+
+ async def test_one_acompletion_counts_its_tokens_and_request_once(
+ self, make_core: MakeCore, host: DeferredLoggingHost
+ ):
+ core: Final = _one_group(make_core, tpm=100, rpm=5)
+ await core.acompletion(model="g", messages=MESSAGES)
+ assert await core.health().usage("g") == (10, 1)
+ await host.drain()
+ assert await core.health().usage("g") == (10, 1)
+
+
+async def test_usage_based_routing_registers_its_logger_on_the_host(make_core: MakeCore, host: ScriptedHost):
+ make_core(model_list=[deployment("g", "d1", rpm=1)], routing_strategy="usage-based-routing-v2")
+ assert [type(callback).__name__ for callback in host.lists["callbacks"]] == ["LowestTPMLoggingHandler_v2"]
+
+
+async def test_a_successful_call_uses_up_the_deployment_rpm_for_the_next_selection(
+ make_core: MakeCore, host: ScriptedHost
+):
+ core: Final = make_core(model_list=[deployment("g", "d1", rpm=1)], routing_strategy="usage-based-routing-v2")
+ await core.acompletion(model="g", messages=MESSAGES)
+ with pytest.raises(litellm.RateLimitError, match="No deployments available"):
+ await core.acompletion(model="g", messages=MESSAGES)
+ assert host.deployments_called() == ["d1"]
+
+
+async def test_host_error_reaches_the_caller_as_raised(make_core: MakeCore, host: ScriptedHost):
+ error: Final = bad_request(message="scripted bad input")
+ host.answer("openai/d1", error)
+ core: Final = _one_group(make_core)
+ with pytest.raises(litellm.BadRequestError, match="scripted bad input"):
+ await core.acompletion(model="g", messages=MESSAGES)
+ assert host.deployments_called("acompletion")[0] == "d1"
+
+
+@pytest.mark.parametrize("endpoint", ["acompletion", "completion"])
+async def test_unknown_model_group_raises_without_calling_the_host(
+ make_core: MakeCore, host: ScriptedHost, endpoint: str
+):
+ core: Final = _one_group(make_core)
+ with pytest.raises(litellm.BadRequestError, match="model=missing"):
+ await core.call(endpoint, {"model": "missing", "messages": MESSAGES})
+ assert host.calls == []
+
+
+@pytest.mark.parametrize("op", ["no_such_endpoint", "acompletions", ""])
+async def test_call_with_an_unknown_op_raises_attribute_error_without_calling_the_host(
+ make_core: MakeCore, host: ScriptedHost, op: str
+):
+ core: Final = _one_group(make_core)
+ with pytest.raises(AttributeError):
+ await core.call(op, {"model": "g"})
+ assert host.calls == []
+
+
+async def test_sync_text_completion_invokes_the_sync_host_op_with_deployment_credentials(
+ make_core: MakeCore, host: ScriptedHost
+):
+ core: Final = _one_group(make_core, api_base="https://d1.example")
+ core.text_completion(model="g", prompt="hi")
+ call: Final = host.calls[0]
+ assert (call.op, call.kwargs["api_base"], call.kwargs["prompt"], call.model_group) == (
+ "text_completion",
+ "https://d1.example",
+ "hi",
+ "g",
+ )
+
+
+def _scripted_stream(*texts: str) -> Callable[..., CustomStreamWrapper]:
+ def answer(**_request: object) -> CustomStreamWrapper: # kwargs-ok: the host passes the request kwargs
+ async def chunks() -> AsyncIterator[ModelResponseStream]:
+ for text in texts:
+ yield ModelResponseStream(choices=[{"index": 0, "delta": {"role": "assistant", "content": text}}])
+ yield ModelResponseStream(choices=[{"index": 0, "delta": {}, "finish_reason": "stop"}])
+
+ logging_obj: Final = LiteLLMLogging(
+ model="d1",
+ messages=MESSAGES,
+ stream=True,
+ call_type="acompletion",
+ start_time=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
+ litellm_call_id="stream-test",
+ function_id="stream-test",
+ )
+ logging_obj.update_environment_variables(model="d1", user=None, optional_params={}, litellm_params={})
+ return CustomStreamWrapper(
+ completion_stream=chunks(), model="d1", custom_llm_provider="openai", logging_obj=logging_obj
+ )
+
+ return answer
+
+
+async def test_streaming_acompletion_yields_the_host_stream_chunks_with_routing_hidden_params(
+ make_core: MakeCore, host: ScriptedHost
+):
+ host.answer("openai/d1", _scripted_stream("he", "llo"))
+ core: Final = _one_group(make_core)
+ stream: Final = await core.acompletion(model="g", messages=MESSAGES, stream=True)
+ texts: Final = [chunk.choices[0].delta.content async for chunk in stream] # pyright: ignore[reportUnknownMemberType] # stream chunks are untyped
+ assert "".join(text for text in texts if text) == "hello"
+ assert stream._hidden_params["model_id"] == "d1"
+ assert stream._hidden_params["additional_headers"]["x-litellm-model-group"] == "g"
+ assert (host.calls[0].op, host.calls[0].kwargs["stream"]) == ("acompletion", True)
diff --git a/tests/unit/test_router/core/test_fallbacks.py b/tests/unit/test_router/core/test_fallbacks.py
new file mode 100644
index 00000000000..e4e5c3120c8
--- /dev/null
+++ b/tests/unit/test_router/core/test_fallbacks.py
@@ -0,0 +1,756 @@
+"""Cross-group fallbacks of a router core: where the chain comes from, how it is walked, and what is raised."""
+
+from collections.abc import Callable, Mapping
+
+import pytest
+
+import litellm
+from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException
+from litellm.router_api import ChangeSet
+from litellm.router_core import RouterCore
+from tests.unit.test_router.core.scripted_host import (
+ ScriptedHost,
+ bad_request,
+ content_policy,
+ context_window,
+ deployment,
+ rate_limit,
+ server_error,
+ timeout,
+)
+
+MakeCore = Callable[..., RouterCore]
+
+MESSAGES = [{"role": "user", "content": "hi"}]
+GROUPS_ABC = [deployment("a", "a"), deployment("b", "b"), deployment("c", "c")]
+GROUPS_ABCD = [*GROUPS_ABC, deployment("d", "d")]
+CHAIN_ABCD = [{"a": ["b"]}, {"b": ["c"]}, {"c": ["d"]}]
+TIERED_A = [deployment("a", "a1", order=1), deployment("a", "a2", order=2), deployment("b", "b")]
+
+
+def build(make_core: MakeCore, **arguments: object) -> RouterCore: # kwargs-ok: the router's constructor arguments
+ return make_core(**{"model_list": GROUPS_ABC, "num_retries": 0, **arguments})
+
+
+async def complete(core: RouterCore, model: str = "a", **request: object) -> object: # kwargs-ok: request kwargs
+ return await core.acompletion(model=model, messages=MESSAGES, **request) # pyright: ignore[reportAttributeAccessIssue] # endpoint method
+
+
+def headers(response: object) -> Mapping[str, object]:
+ return getattr(response, "_hidden_params")["additional_headers"]
+
+
+def show_router_debug(host: ScriptedHost) -> None:
+ host.set_dynamic(expose_router_debug_in_errors=True)
+
+
+# --- where the fallback chain comes from -------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("constructor", "process", "request_kwargs", "expected"),
+ [
+ ({"fallbacks": [{"a": ["b"]}]}, None, {}, ["a", "b"]),
+ ({}, [{"a": ["c"]}], {}, ["a", "c"]),
+ ({}, None, {"fallbacks": [{"a": ["b"]}]}, ["a", "b"]),
+ ({}, None, {"fallbacks": ["c"]}, ["a", "c"]),
+ ({"fallbacks": [{"a": ["b"]}]}, [{"a": ["c"]}], {}, ["a", "b"]),
+ ({"fallbacks": [{"a": ["b"]}]}, None, {"fallbacks": [{"a": ["c"]}]}, ["a", "c"]),
+ ],
+ ids=[
+ "constructor",
+ "process-setting",
+ "request-kwarg",
+ "request-plain-list",
+ "constructor-over-process-setting",
+ "request-over-constructor",
+ ],
+)
+async def test_a_failed_group_falls_back_to_the_chain_from_the_winning_source(
+ make_core: MakeCore,
+ host: ScriptedHost,
+ constructor: dict[str, object],
+ process: list[object] | None,
+ request_kwargs: dict[str, object],
+ expected: list[str],
+) -> None:
+ if process is not None:
+ host.set_dynamic(fallbacks=process)
+ core = build(make_core, **constructor)
+ host.answer("openai/a", rate_limit())
+
+ response = await complete(core, **request_kwargs)
+
+ assert host.deployments_called("acompletion") == expected
+ assert headers(response)["x-litellm-model-group"] == expected[-1]
+
+
+@pytest.mark.asyncio
+async def test_an_empty_request_fallback_list_overrides_the_constructor_chain(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}])
+ host.answer("openai/a", rate_limit())
+
+ with pytest.raises(litellm.RateLimitError):
+ await complete(core, fallbacks=[])
+
+ assert host.deployments_called("acompletion") == ["a"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("field", ["assign", "settings"])
+async def test_fallbacks_applied_by_changeset_take_effect(make_core: MakeCore, host: ScriptedHost, field: str) -> None:
+ core = build(make_core)
+ core.apply(ChangeSet(**{field: {"fallbacks": [{"a": ["c"]}]}})) # pyright: ignore[reportArgumentType] # assign or settings
+ host.answer("openai/a", rate_limit())
+
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a", "c"]
+
+
+# --- resolving the group's chain ----------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_the_generic_star_chain_catches_a_group_with_no_entry(make_core: MakeCore, host: ScriptedHost) -> None:
+ core = build(make_core, fallbacks=[{"b": ["a"]}, {"*": ["c"]}])
+ host.answer("openai/a", rate_limit())
+
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a", "c"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "fallbacks",
+ [[{"*": ["c"]}, {"a": ["b"]}], [{"a": ["b"]}, {"*": ["c"]}]],
+ ids=["star-first", "star-last"],
+)
+async def test_a_group_specific_chain_wins_over_the_generic_star_chain(
+ make_core: MakeCore, host: ScriptedHost, fallbacks: list[object]
+) -> None:
+ core = build(make_core, fallbacks=fallbacks)
+ host.answer("openai/a", rate_limit())
+
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a", "b"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("group", "fallback_key"),
+ [("openai/gpt-x", "gpt-x"), ("gpt-4o", "openai/gpt-4o")],
+ ids=["provider-stripped-key", "provider-prefixed-key"],
+)
+async def test_a_chain_keyed_with_or_without_the_provider_prefix_matches_the_group(
+ make_core: MakeCore, host: ScriptedHost, group: str, fallback_key: str
+) -> None:
+ core = build(
+ make_core, model_list=[deployment(group, "p"), deployment("b", "b")], fallbacks=[{fallback_key: ["b"]}]
+ )
+ host.answer("openai/p", rate_limit())
+
+ await complete(core, model=group)
+
+ assert host.deployments_called("acompletion") == ["p", "b"]
+
+
+@pytest.mark.asyncio
+async def test_a_prefixed_key_does_not_match_a_group_whose_provider_cannot_be_inferred(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = build(
+ make_core, model_list=[deployment("mygroup", "p"), deployment("b", "b")], fallbacks=[{"openai/mygroup": ["b"]}]
+ )
+ host.answer("openai/p", rate_limit())
+
+ with pytest.raises(litellm.RateLimitError):
+ await complete(core, model="mygroup")
+
+ assert host.deployments_called("acompletion") == ["p"]
+
+
+@pytest.mark.asyncio
+async def test_a_dict_target_names_its_group_under_model(make_core: MakeCore, host: ScriptedHost) -> None:
+ core = build(make_core, fallbacks=[{"a": [{"model": "c"}]}])
+ host.answer("openai/a", rate_limit())
+
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a", "c"]
+
+
+# --- walking the chain ------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("failing", "expected"),
+ [(["a"], ["a", "b"]), (["a", "b"], ["a", "b", "c"])],
+ ids=["first-target-answers", "second-target-answers"],
+)
+async def test_targets_are_tried_in_listed_order_until_one_answers(
+ make_core: MakeCore, host: ScriptedHost, failing: list[str], expected: list[str]
+) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b", "c"]}])
+ for group in failing:
+ host.answer(f"openai/{group}", rate_limit())
+
+ response = await complete(core)
+
+ assert host.deployments_called("acompletion") == expected
+ assert headers(response)["x-litellm-model-group"] == expected[-1]
+
+
+@pytest.mark.asyncio
+async def test_a_failed_fallback_group_follows_its_own_chain(make_core: MakeCore, host: ScriptedHost) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}, {"b": ["c"]}])
+ host.answer("openai/a", rate_limit())
+ host.answer("openai/b", rate_limit())
+
+ response = await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a", "b", "c"]
+ assert headers(response)["x-litellm-model-group"] == "c"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("fallbacks", "expected"),
+ [
+ ([{"a": ["b"]}, {"b": ["a", "c"]}, {"c": ["a", "b"]}], ["a", "b", "c"]),
+ ([{"a": ["a", "b"]}], ["a", "b"]),
+ ([{"a": ["b", "b", "c"]}], ["a", "b", "c"]),
+ ],
+ ids=["cyclic-graph", "group-lists-itself", "target-listed-twice"],
+)
+async def test_no_group_is_tried_twice_in_one_request(
+ make_core: MakeCore, host: ScriptedHost, fallbacks: list[object], expected: list[str]
+) -> None:
+ core = build(make_core, fallbacks=fallbacks)
+ host.answer("*", rate_limit())
+
+ with pytest.raises(litellm.RateLimitError):
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == expected
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("constructor", "process", "expected"),
+ [
+ ({"max_fallbacks": 0}, {}, ["a"]),
+ ({"max_fallbacks": 1}, {}, ["a", "b"]),
+ ({"max_fallbacks": 2}, {}, ["a", "b", "c"]),
+ ({}, {"max_fallbacks": 2}, ["a", "b", "c"]),
+ ({}, {"router_max_fallbacks": 2}, ["a", "b", "c"]),
+ ({}, {}, ["a", "b", "c", "d"]),
+ ({"max_fallbacks": 3}, {"max_fallbacks": 1}, ["a", "b", "c", "d"]),
+ ({}, {"max_fallbacks": 1, "router_max_fallbacks": 3}, ["a", "b"]),
+ ],
+ ids=[
+ "constructor-0",
+ "constructor-1",
+ "constructor-2",
+ "process-max_fallbacks-2",
+ "process-router_max_fallbacks-2",
+ "default",
+ "constructor-over-process",
+ "process-max_fallbacks-over-router_max_fallbacks",
+ ],
+)
+async def test_max_fallbacks_bounds_the_depth_of_a_chained_walk(
+ make_core: MakeCore,
+ host: ScriptedHost,
+ constructor: dict[str, object],
+ process: dict[str, object],
+ expected: list[str],
+) -> None:
+ if process:
+ host.set_dynamic(**process)
+ core = build(make_core, model_list=GROUPS_ABCD, fallbacks=CHAIN_ABCD, **constructor)
+ host.answer("*", rate_limit())
+
+ with pytest.raises(litellm.RateLimitError):
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == expected
+
+
+@pytest.mark.asyncio
+async def test_max_fallbacks_applied_by_changeset_bounds_the_walk(make_core: MakeCore, host: ScriptedHost) -> None:
+ core = build(make_core, model_list=GROUPS_ABCD, fallbacks=CHAIN_ABCD)
+ core.apply(ChangeSet(assign={"max_fallbacks": 1}))
+ host.answer("*", rate_limit())
+
+ with pytest.raises(litellm.RateLimitError):
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a", "b"]
+
+
+@pytest.mark.asyncio
+async def test_a_fallback_group_with_no_deployments_is_passed_over(make_core: MakeCore, host: ScriptedHost) -> None:
+ core = build(make_core, fallbacks=[{"a": ["missing", "c"]}])
+ host.answer("openai/a", rate_limit())
+
+ response = await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a", "c"]
+ assert headers(response)["x-litellm-model-group"] == "c"
+
+
+@pytest.mark.asyncio
+async def test_a_chain_of_only_empty_groups_raises_the_primary_error(make_core: MakeCore, host: ScriptedHost) -> None:
+ core = build(make_core, fallbacks=[{"a": ["missing"]}])
+ host.answer("openai/a", rate_limit())
+
+ with pytest.raises(litellm.RateLimitError):
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a"]
+
+
+@pytest.mark.asyncio
+async def test_a_fallback_group_runs_after_the_primary_group_spends_its_retries(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}], num_retries=2)
+ host.answer("openai/a", rate_limit())
+
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a", "a", "a", "b"]
+
+
+# --- error-specific chains ---------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("error", "field"),
+ [(context_window, "context_window_fallbacks"), (content_policy, "content_policy_fallbacks")],
+ ids=["context-window", "content-policy"],
+)
+@pytest.mark.parametrize("source", ["constructor", "process-setting", "request-kwarg"])
+async def test_an_error_specific_chain_wins_over_the_general_chain(
+ make_core: MakeCore, host: ScriptedHost, error: Callable[[], Exception], field: str, source: str
+) -> None:
+ chain = [{"a": ["c"]}]
+ if source == "process-setting":
+ host.set_dynamic(**{field: chain})
+ core = build(make_core, fallbacks=[{"a": ["b"]}], **({field: chain} if source == "constructor" else {}))
+ host.answer("openai/a", error())
+
+ response = await complete(core, **({field: chain} if source == "request-kwarg" else {}))
+
+ assert host.deployments_called("acompletion") == ["a", "c"]
+ assert headers(response)["x-litellm-model-group"] == "c"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("error", "field", "raised"),
+ [
+ (context_window, "context_window_fallbacks", litellm.ContextWindowExceededError),
+ (content_policy, "content_policy_fallbacks", litellm.ContentPolicyViolationError),
+ ],
+ ids=["context-window", "content-policy"],
+)
+async def test_an_error_specific_chain_without_the_group_raises_without_trying_the_general_chain(
+ make_core: MakeCore, host: ScriptedHost, error: Callable[[], Exception], field: str, raised: type[Exception]
+) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}], **{field: [{"other": ["c"]}]})
+ host.answer("openai/a", error())
+
+ with pytest.raises(raised):
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("error", [context_window, content_policy], ids=["context-window", "content-policy"])
+async def test_without_an_error_specific_chain_the_general_chain_applies(
+ make_core: MakeCore, host: ScriptedHost, error: Callable[[], Exception]
+) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}])
+ host.answer("openai/a", error())
+
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a", "b"]
+
+
+@pytest.mark.asyncio
+async def test_a_context_window_fallback_that_fails_otherwise_follows_the_general_chain(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = build(make_core, context_window_fallbacks=[{"a": ["b"]}], fallbacks=[{"b": ["c"]}])
+ host.answer("openai/a", context_window())
+ host.answer("openai/b", rate_limit())
+
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a", "b", "c"]
+
+
+# --- default fallbacks -------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("source", ["constructor", "process-setting"])
+async def test_default_fallbacks_catch_any_group(make_core: MakeCore, host: ScriptedHost, source: str) -> None:
+ if source == "process-setting":
+ host.set_dynamic(default_fallbacks=["c"])
+ core = build(make_core, **({"default_fallbacks": ["c"]} if source == "constructor" else {}))
+ host.answer("openai/a", rate_limit())
+
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a", "c"]
+
+
+@pytest.mark.asyncio
+async def test_default_fallbacks_join_the_configured_chains_as_the_star_entry(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = build(make_core, fallbacks=[{"b": ["a"]}], default_fallbacks=["c"])
+ host.answer("openai/a", rate_limit())
+
+ await complete(core)
+
+ assert core.settings().fallbacks == [{"b": ["a"]}, {"*": ["c"]}]
+ assert host.deployments_called("acompletion") == ["a", "c"]
+
+
+def test_default_fallbacks_given_as_a_string_are_refused(make_core: MakeCore) -> None:
+ with pytest.raises(TypeError, match=r"default_fallbacks must be a list of model names, got 'c'"):
+ build(make_core, default_fallbacks="c")
+
+
+# --- order tiers inside one group ----------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("failing", "expected", "model_group"),
+ [
+ (["a1"], ["a1", "a2"], "a"),
+ (["a1", "a2"], ["a1", "a2", "b"], "b"),
+ ],
+ ids=["next-tier-answers", "external-fallback-after-every-tier"],
+)
+async def test_order_tiers_are_tried_before_the_external_fallbacks(
+ make_core: MakeCore, host: ScriptedHost, failing: list[str], expected: list[str], model_group: str
+) -> None:
+ core = build(make_core, model_list=TIERED_A, fallbacks=[{"a": ["b"]}])
+ for deployment_id in failing:
+ host.answer(f"openai/{deployment_id}", rate_limit())
+
+ response = await complete(core)
+
+ assert host.deployments_called("acompletion") == expected
+ assert headers(response)["x-litellm-model-group"] == model_group
+
+
+@pytest.mark.asyncio
+async def test_order_tiers_are_walked_without_any_configured_fallbacks(make_core: MakeCore, host: ScriptedHost) -> None:
+ core = build(make_core, model_list=[*TIERED_A, deployment("a", "a3", order=3)])
+ host.answer("*", rate_limit())
+
+ with pytest.raises(litellm.RateLimitError):
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a1", "a2", "a3"]
+
+
+@pytest.mark.asyncio
+async def test_a_context_window_error_skips_the_order_tiers_for_its_own_chain(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = build(make_core, model_list=TIERED_A, context_window_fallbacks=[{"a": ["b"]}])
+ host.answer("openai/a1", context_window())
+
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a1", "b"]
+
+
+# --- when no fallback runs -----------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_disable_fallbacks_on_the_request_raises_the_primary_error(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}])
+ host.answer("openai/a", rate_limit())
+
+ with pytest.raises(litellm.RateLimitError):
+ await complete(core, disable_fallbacks=True)
+
+ assert host.deployments_called("acompletion") == ["a"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "error",
+ [
+ GuardrailRaisedException(guardrail_name="g", message="no"),
+ BlockedPiiEntityError(entity_type="EMAIL", guardrail_name="g"),
+ ],
+ ids=["guardrail-raised", "blocked-pii"],
+)
+async def test_a_guardrail_intervention_never_falls_back(
+ make_core: MakeCore, host: ScriptedHost, error: Exception
+) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}])
+ host.answer("openai/a", error)
+
+ with pytest.raises(type(error)) as raised:
+ await complete(core)
+
+ assert raised.value is host.raised[-1]
+ assert host.deployments_called("acompletion") == ["a"]
+
+
+def unsupported_params() -> litellm.UnsupportedParamsError:
+ return litellm.UnsupportedParamsError(message="nope", llm_provider="openai", model="a")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("error", [bad_request, unsupported_params], ids=["bad-request", "unsupported-params"])
+async def test_a_bad_request_does_not_fall_back_when_fallback_on_unsupported_params_is_off(
+ make_core: MakeCore, host: ScriptedHost, error: Callable[[], Exception]
+) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}], fallback_on_unsupported_params=False)
+ host.answer("openai/a", error())
+
+ with pytest.raises(type(error())):
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("error", "fallback_on_unsupported_params"),
+ [
+ (bad_request, True),
+ (unsupported_params, True),
+ (context_window, False),
+ (content_policy, False),
+ (rate_limit, False),
+ (server_error, False),
+ ],
+ ids=[
+ "bad-request-flag-on",
+ "unsupported-params-flag-on",
+ "context-window-flag-off",
+ "content-policy-flag-off",
+ "rate-limit-flag-off",
+ "server-error-flag-off",
+ ],
+)
+async def test_errors_outside_the_unsupported_params_rule_fall_back(
+ make_core: MakeCore, host: ScriptedHost, error: Callable[[], Exception], fallback_on_unsupported_params: bool
+) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}], fallback_on_unsupported_params=fallback_on_unsupported_params)
+ host.answer("openai/a", error())
+
+ await complete(core)
+
+ assert host.deployments_called("acompletion") == ["a", "b"]
+
+
+# --- the error when every group fails --------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_when_every_fallback_fails_the_primary_groups_error_is_raised(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b", "c"]}], max_fallbacks=1)
+ host.answer("openai/a", rate_limit())
+ host.answer("openai/b", timeout())
+ host.answer("openai/c", server_error())
+
+ with pytest.raises(litellm.RateLimitError) as raised:
+ await complete(core)
+
+ assert str(raised.value) == "litellm.RateLimitError: slow down"
+ assert host.deployments_called("acompletion") == ["a", "b", "c"]
+
+
+@pytest.mark.asyncio
+async def test_without_router_debug_the_error_message_is_not_extended(make_core: MakeCore, host: ScriptedHost) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}])
+ host.answer("*", rate_limit())
+
+ with pytest.raises(litellm.RateLimitError) as raised:
+ await complete(core)
+
+ assert str(raised.value) == "litellm.RateLimitError: slow down"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("arguments", "suffix"),
+ [
+ ({}, "\n\nLiteLLM: model group 'a' failed with the error above. No fallback was attempted."),
+ (
+ {"fallbacks": [{"a": ["b", "c"]}]},
+ "\n\nLiteLLM: model group 'a' failed with the error above. "
+ "Fallback to b, c also failed: litellm.RateLimitError: slow down",
+ ),
+ (
+ {"fallbacks": [{"a": ["b"]}, {"b": ["c"]}]},
+ "\n\nLiteLLM: model group 'a' failed with the error above. "
+ "Fallback to b also failed: litellm.RateLimitError: slow down",
+ ),
+ (
+ {"fallbacks": [{"b": ["c"]}]},
+ "\n\nLiteLLM: model group 'a' failed with the error above and no fallback model group was found for it, "
+ "so the request was not retried on another model. Fallbacks are configured for: b. "
+ "Add a fallbacks entry for that model group (Router fallbacks or proxy router_settings.fallbacks) "
+ "to retry on another model.",
+ ),
+ ],
+ ids=["no-fallbacks", "fallback-list-failed", "chained-fallback-failed", "no-chain-for-group"],
+)
+async def test_router_debug_explains_the_fallback_outcome_once(
+ make_core: MakeCore, host: ScriptedHost, arguments: dict[str, object], suffix: str
+) -> None:
+ core = build(make_core, **arguments)
+ show_router_debug(host)
+ host.answer("*", rate_limit())
+
+ with pytest.raises(litellm.RateLimitError) as raised:
+ await complete(core)
+
+ assert str(raised.value) == f"litellm.RateLimitError: slow down{suffix}"
+
+
+@pytest.mark.asyncio
+async def test_router_debug_names_the_first_fallback_groups_error(make_core: MakeCore, host: ScriptedHost) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}])
+ show_router_debug(host)
+ host.answer("openai/a", rate_limit())
+ host.answer("openai/b", server_error())
+
+ with pytest.raises(litellm.RateLimitError) as raised:
+ await complete(core)
+
+ assert str(raised.value).endswith(
+ "LiteLLM: model group 'a' failed with the error above. Fallback to b also failed: litellm.InternalServerError: boom"
+ )
+
+
+@pytest.mark.asyncio
+async def test_router_debug_reports_a_fallback_group_with_no_deployments(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = build(make_core, fallbacks=[{"a": ["missing"]}])
+ show_router_debug(host)
+ host.answer("openai/a", rate_limit())
+
+ with pytest.raises(litellm.RateLimitError) as raised:
+ await complete(core)
+
+ assert (
+ "LiteLLM: model group 'a' failed with the error above. Fallback to missing also failed: "
+ "litellm.BadRequestError: You passed in model=missing. There are no healthy deployments for this model"
+ ) in str(raised.value)
+
+
+@pytest.mark.asyncio
+async def test_router_debug_on_an_unmatched_context_window_chain_says_no_fallback_was_attempted(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = build(make_core, context_window_fallbacks=[{"other": ["c"]}])
+ show_router_debug(host)
+ host.answer("openai/a", context_window())
+
+ with pytest.raises(litellm.ContextWindowExceededError) as raised:
+ await complete(core)
+
+ assert str(raised.value).endswith(
+ "\n\nLiteLLM: model group 'a' failed with the error above. No fallback was attempted."
+ )
+
+
+@pytest.mark.asyncio
+async def test_router_debug_on_content_policy_without_any_chain_hints_at_content_policy_fallbacks(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = build(make_core)
+ show_router_debug(host)
+ host.answer("openai/a", content_policy())
+
+ with pytest.raises(litellm.ContentPolicyViolationError) as raised:
+ await complete(core)
+
+ message = str(raised.value)
+ assert "model=a. content_policy_fallback=None. fallbacks=None." in message
+ assert message.endswith("LiteLLM: model group 'a' failed with the error above. No fallback was attempted.")
+
+
+# --- response headers ------------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("failing", "attempted", "model_group"),
+ [([], 0, "a"), (["a"], 1, "b")],
+ ids=["primary-answers", "one-fallback"],
+)
+async def test_success_headers_name_the_answering_group_and_the_fallbacks_attempted(
+ make_core: MakeCore, host: ScriptedHost, failing: list[str], attempted: int, model_group: str
+) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}])
+ for group in failing:
+ host.answer(f"openai/{group}", rate_limit())
+
+ response = await complete(core)
+
+ assert headers(response)["x-litellm-attempted-fallbacks"] == attempted
+ assert headers(response)["x-litellm-model-group"] == model_group
+
+
+@pytest.mark.asyncio
+async def test_each_attempt_carries_its_own_model_group_to_the_host(make_core: MakeCore, host: ScriptedHost) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}])
+ host.answer("openai/a", rate_limit())
+
+ await core.call("acompletion", {"model": "a", "messages": MESSAGES})
+
+ assert [call.model_group for call in host.calls_to("acompletion")] == ["a", "b"]
+ assert [call.model for call in host.calls_to("acompletion")] == ["openai/a", "openai/b"]
+
+
+@pytest.mark.asyncio
+async def test_embedding_falls_back_like_completion(make_core: MakeCore, host: ScriptedHost) -> None:
+ core = build(make_core, model_list=[deployment("e", "e"), deployment("f", "f")], fallbacks=[{"e": ["f"]}])
+ host.answer("openai/e", rate_limit())
+
+ response = await core.aembedding(model="e", input=["x"]) # pyright: ignore[reportAttributeAccessIssue] # endpoint method
+
+ assert host.deployments_called("aembedding") == ["e", "f"]
+ assert headers(response)["x-litellm-attempted-fallbacks"] == 1
+
+
+def test_sync_completion_falls_back_like_async(make_core: MakeCore, host: ScriptedHost) -> None:
+ core = build(make_core, fallbacks=[{"a": ["b"]}])
+ host.answer("openai/a", rate_limit())
+
+ response = core.completion(model="a", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # endpoint method
+
+ assert host.deployments_called("completion") == ["a", "b"]
+ assert headers(response)["x-litellm-attempted-fallbacks"] == 1
diff --git a/tests/unit/test_router/core/test_registry_changesets.py b/tests/unit/test_router/core/test_registry_changesets.py
new file mode 100644
index 00000000000..d4aa4d14a75
--- /dev/null
+++ b/tests/unit/test_router/core/test_registry_changesets.py
@@ -0,0 +1,657 @@
+"""
+The core's registry and its settings, which are static after construction: ``apply(ChangeSet)``
+(and ``aapply``) is the only way to change them, and a direct attribute write is routed into one.
+"""
+
+import copy
+from collections.abc import Callable, Mapping
+
+import pytest
+
+import litellm
+from litellm.router_api import ApplyResult, ChangeSet
+from litellm.router_core import RouterCore
+from litellm.types.router import Deployment
+from tests.unit.test_router.core.scripted_host import (
+ ScriptedHost,
+ context_window,
+ deployment,
+ server_error,
+)
+
+MakeCore = Callable[..., RouterCore]
+
+MESSAGES = [{"role": "user", "content": "hi"}]
+
+
+def _model_list() -> list[dict[str, object]]: # mutable-ok: a fresh model_list
+ return [deployment("gpt", "a"), deployment("gpt", "b"), deployment("emb", "e")]
+
+
+def _fresh(error: Callable[[], BaseException]) -> Callable[..., BaseException]:
+ """A scripted outcome raising a new exception per call: the core stamps num_retries on the instance it saw."""
+ return lambda **_: error()
+
+
+def _ids(deployments: object) -> list[str]: # mutable-ok: a fresh list
+ return [entry["model_info"]["id"] for entry in deployments] # pyright: ignore[reportGeneralTypeIssues, reportIndexIssue] # deployment dicts
+
+
+def _deployment(model_name: str, deployment_id: str, **params: object) -> Deployment: # kwargs-ok: litellm_params
+ return Deployment(**deployment(model_name, deployment_id, **params)) # pyright: ignore[reportArgumentType] # a model_list entry
+
+
+# -- registry lookups after construction --------------------------------------------------------
+
+
+@pytest.mark.parametrize(("deployment_id", "group"), [("b", "gpt"), ("e", "emb")])
+def test_registry_finds_each_configured_deployment_by_id(make_core: MakeCore, deployment_id: str, group: str) -> None:
+ registry = make_core(model_list=_model_list()).registry()
+
+ found = registry.deployment(deployment_id)
+
+ assert found is not None
+ assert (found.model_name, found.model_info.id, found.litellm_params.model) == (
+ group,
+ deployment_id,
+ f"openai/{deployment_id}",
+ )
+ assert registry.has_deployment(deployment_id)
+
+
+def test_registry_has_no_deployment_for_an_unknown_id(make_core: MakeCore) -> None:
+ registry = make_core(model_list=_model_list()).registry()
+
+ assert registry.deployment("missing") is None
+ assert not registry.has_deployment("missing")
+
+
+@pytest.mark.parametrize(("group", "ids"), [("gpt", ["a", "b"]), ("unknown", [])])
+def test_registry_lists_a_groups_deployments_in_configured_order(
+ make_core: MakeCore, group: str, ids: list[str]
+) -> None:
+ registry = make_core(model_list=_model_list()).registry()
+
+ assert _ids(registry.deployments(group)) == ids
+ assert registry.names(group, kind="ids") == ids
+
+
+def test_registry_names_every_group_and_deployment_id(make_core: MakeCore) -> None:
+ registry = make_core(model_list=_model_list()).registry()
+
+ assert registry.names(kind="group_set") == {"gpt", "emb"}
+ assert registry.names(kind="ids") == ["a", "b", "e"]
+ assert _ids(registry.deployments()) == ["a", "b", "e"]
+
+
+@pytest.mark.parametrize(
+ ("name", "kind", "resolved"),
+ [
+ ("fast", "group", "gpt"),
+ ("fast", "alias", "gpt"),
+ ("gpt", "alias", None),
+ ("e", "from_id", "emb"),
+ ("fast", "recognized", True),
+ ("nope", "recognized", False),
+ ],
+)
+def test_registry_resolves_names_through_groups_aliases_and_ids(
+ make_core: MakeCore, name: str, kind: str, resolved: object
+) -> None:
+ registry = make_core(model_list=_model_list(), model_group_alias={"fast": "gpt"}).registry()
+
+ assert registry.resolve(name, kind=kind) == resolved # pyright: ignore[reportCallIssue, reportArgumentType] # kind is one of the literals
+
+
+def test_registry_aliases_are_the_configured_model_group_alias(make_core: MakeCore) -> None:
+ registry = make_core(model_list=_model_list(), model_group_alias={"fast": "gpt"}).registry()
+
+ assert registry.aliases() == {"fast": "gpt"}
+ assert _ids(registry.deployments("fast")) == ["a", "b"]
+
+
+def test_registry_version_is_unchanged_by_reads(make_core: MakeCore) -> None:
+ core = make_core(model_list=_model_list())
+ before = core.registry().version
+
+ core.registry().deployments("gpt")
+ core.registry().names()
+ core.registry().resolve("gpt")
+ core.settings()
+
+ assert core.registry().version == before
+
+
+# -- registry().version on every write path -----------------------------------------------------
+
+
+async def _apply_upsert_new(core: RouterCore) -> None:
+ core.apply(ChangeSet(upsert=[_deployment("gpt", "c")]))
+
+
+async def _apply_upsert_changed(core: RouterCore) -> None:
+ core.apply(ChangeSet(upsert=[_deployment("gpt", "a", api_base="https://changed.example")]))
+
+
+async def _apply_delete(core: RouterCore) -> None:
+ core.apply(ChangeSet(delete=["a"]))
+
+
+async def _aapply_upsert_new(core: RouterCore) -> None:
+ await core.aapply(ChangeSet(upsert=[_deployment("gpt", "c")])) # pyright: ignore[reportAttributeAccessIssue] # aapply is on every core
+
+
+async def _add_deployment(core: RouterCore) -> None:
+ core.add_deployment(_deployment("gpt", "c")) # pyright: ignore[reportAttributeAccessIssue] # public on the core
+
+
+async def _set_model_list(core: RouterCore) -> None:
+ core.set_model_list([deployment("gpt", "c")]) # pyright: ignore[reportAttributeAccessIssue] # public on the core
+
+
+async def _upsert_deployment(core: RouterCore) -> None:
+ with pytest.deprecated_call():
+ core.upsert_deployment(_deployment("gpt", "c")) # pyright: ignore[reportAttributeAccessIssue] # deprecated, still public
+
+
+async def _delete_deployment(core: RouterCore) -> None:
+ with pytest.deprecated_call():
+ core.delete_deployment("a") # pyright: ignore[reportAttributeAccessIssue] # deprecated, still public
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("write", "ids_after"),
+ [
+ (_apply_upsert_new, ["a", "b", "e", "c"]),
+ (_apply_upsert_changed, ["a", "b", "e"]),
+ (_apply_delete, ["b", "e"]),
+ (_aapply_upsert_new, ["a", "b", "e", "c"]),
+ (_add_deployment, ["a", "b", "e", "c"]),
+ (_set_model_list, ["c"]),
+ (_upsert_deployment, ["a", "b", "e", "c"]),
+ (_delete_deployment, ["b", "e"]),
+ ],
+ ids=lambda value: getattr(value, "__name__", None),
+)
+async def test_every_deployment_write_advances_the_registry_version(
+ make_core: MakeCore, write: Callable[[RouterCore], object], ids_after: list[str]
+) -> None:
+ core = make_core(model_list=_model_list())
+ before = core.registry().version
+
+ await write(core) # pyright: ignore[reportGeneralTypeIssues] # each write is a coroutine function
+
+ assert core.registry().version > before
+ assert sorted(core.registry().names(kind="ids")) == sorted(ids_after)
+
+
+@pytest.mark.asyncio
+async def test_a_changed_upsert_is_what_the_registry_returns_afterwards(make_core: MakeCore) -> None:
+ core = make_core(model_list=_model_list())
+
+ await _apply_upsert_changed(core)
+
+ changed = core.registry().deployment("a")
+ assert changed is not None
+ assert changed.litellm_params.api_base == "https://changed.example"
+
+
+@pytest.mark.parametrize(
+ "changes",
+ [
+ ChangeSet(),
+ ChangeSet(upsert=[_deployment("gpt", "a")]),
+ ChangeSet(delete=["missing"]),
+ ChangeSet(assign={"num_retries": 4, "cooldown_time": 9}),
+ ChangeSet(settings={"num_retries": 4, "allowed_fails": 2}),
+ ],
+ ids=["empty", "identical_upsert", "unknown_delete", "assign", "settings"],
+)
+def test_a_changeset_that_changes_no_deployment_keeps_the_registry_version(
+ make_core: MakeCore, changes: ChangeSet
+) -> None:
+ core = make_core(model_list=_model_list())
+ before = core.registry().version
+
+ result = core.apply(changes)
+
+ assert core.registry().version == before
+ assert result.version == before
+
+
+def test_adding_a_deployment_whose_id_exists_changes_nothing(make_core: MakeCore) -> None:
+ core = make_core(model_list=_model_list())
+ before = core.registry().version
+
+ added = core.add_deployment(_deployment("emb", "a")) # pyright: ignore[reportAttributeAccessIssue] # public on the core
+
+ assert added is None
+ assert core.registry().version == before
+ assert core.registry().resolve("a", kind="from_id") == "gpt"
+
+
+# -- ApplyResult --------------------------------------------------------------------------------
+
+
+def test_apply_result_carries_the_new_version_and_the_upserted_deployment(make_core: MakeCore) -> None:
+ core = make_core(model_list=_model_list())
+
+ result = core.apply(ChangeSet(upsert=[_deployment("gpt", "c")]))
+
+ assert isinstance(result, ApplyResult)
+ assert result.version == core.registry().version
+ assert [None if d is None else d.model_info.id for d in result.upserted] == ["c"]
+ assert list(result.deleted) == []
+
+
+def test_apply_result_reports_none_for_an_upsert_that_changed_nothing(make_core: MakeCore) -> None:
+ core = make_core(model_list=_model_list())
+
+ result = core.apply(ChangeSet(upsert=[_deployment("gpt", "a"), _deployment("gpt", "c")]))
+
+ assert [None if d is None else d.model_info.id for d in result.upserted] == [None, "c"]
+
+
+def test_apply_result_lists_each_delete_in_order_with_none_for_an_unknown_id(make_core: MakeCore) -> None:
+ core = make_core(model_list=_model_list())
+
+ result = core.apply(ChangeSet(delete=["e", "missing", "a"]))
+
+ assert [None if d is None else d["model_info"]["id"] for d in result.deleted] == ["e", None, "a"] # pyright: ignore[reportIndexIssue] # the core returns the model_list dict
+ assert core.registry().names(kind="ids") == ["b"]
+
+
+def test_apply_upserts_before_it_deletes(make_core: MakeCore) -> None:
+ core = make_core(model_list=_model_list())
+
+ result = core.apply(ChangeSet(upsert=[_deployment("gpt", "c")], delete=["c"]))
+
+ assert [d is not None for d in result.upserted] == [True]
+ assert [d is not None for d in result.deleted] == [True]
+ assert not core.registry().has_deployment("c")
+
+
+def test_settings_and_deployments_change_together_in_one_apply(make_core: MakeCore) -> None:
+ core = make_core(model_list=_model_list())
+
+ result = core.apply(ChangeSet(assign={"cooldown_time": 17}, upsert=[_deployment("emb", "f")], delete=["b"]))
+
+ assert result.version == core.registry().version
+ assert core.settings().cooldown_time == 17
+ assert core.registry().names("emb", kind="ids") == ["e", "f"]
+ assert core.registry().names("gpt", kind="ids") == ["a"]
+
+
+# -- settings changed by a changeset change later calls -----------------------------------------
+
+
+def _assign(**values: object) -> ChangeSet: # kwargs-ok: settings by name
+ return ChangeSet(assign=values)
+
+
+def _settings(**values: object) -> ChangeSet: # kwargs-ok: settings by name
+ return ChangeSet(settings=values)
+
+
+CHANGESET_KINDS = pytest.mark.parametrize("changeset", [_assign, _settings], ids=["assign", "settings"])
+
+
+@pytest.mark.asyncio
+@CHANGESET_KINDS
+@pytest.mark.parametrize("num_retries", [0, 3])
+async def test_num_retries_from_a_changeset_governs_the_next_failing_call(
+ make_core: MakeCore, host: ScriptedHost, changeset: Callable[..., ChangeSet], num_retries: int
+) -> None:
+ core = make_core(model_list=[deployment("gpt", "a")], num_retries=2)
+ host.answer("openai/a", _fresh(server_error))
+ with pytest.raises(litellm.InternalServerError):
+ await core.acompletion(model="gpt", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+ host.calls.clear()
+
+ core.apply(changeset(num_retries=num_retries))
+ with pytest.raises(litellm.InternalServerError):
+ await core.acompletion(model="gpt", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+
+ assert host.deployments_called("acompletion") == ["a"] * (num_retries + 1)
+ assert core.settings().as_dict()["num_retries"] == num_retries
+
+
+@pytest.mark.asyncio
+@CHANGESET_KINDS
+async def test_fallbacks_from_a_changeset_are_used_by_the_next_call(
+ make_core: MakeCore, host: ScriptedHost, changeset: Callable[..., ChangeSet]
+) -> None:
+ core = make_core(model_list=[deployment("gpt", "a"), deployment("backup", "f")], num_retries=0)
+ host.answer("openai/a", _fresh(server_error))
+ with pytest.raises(litellm.InternalServerError):
+ await core.acompletion(model="gpt", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+ host.calls.clear()
+
+ core.apply(changeset(fallbacks=[{"gpt": ["backup"]}]))
+ response = await core.acompletion(model="gpt", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+
+ assert host.deployments_called("acompletion") == ["a", "f"]
+ assert response._hidden_params["additional_headers"]["x-litellm-attempted-fallbacks"] == 1
+ assert core.settings().fallbacks == [{"gpt": ["backup"]}]
+
+
+@pytest.mark.asyncio
+@CHANGESET_KINDS
+async def test_context_window_fallbacks_from_a_changeset_are_used_by_the_next_call(
+ make_core: MakeCore, host: ScriptedHost, changeset: Callable[..., ChangeSet]
+) -> None:
+ core = make_core(model_list=[deployment("gpt", "a"), deployment("long", "l")], num_retries=0)
+ host.answer("openai/a", _fresh(context_window))
+ with pytest.raises(litellm.ContextWindowExceededError):
+ await core.acompletion(model="gpt", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+ host.calls.clear()
+
+ core.apply(changeset(context_window_fallbacks=[{"gpt": ["long"]}]))
+ response = await core.acompletion(model="gpt", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+
+ assert host.deployments_called("acompletion") == ["a", "l"]
+ assert response._hidden_params["additional_headers"]["x-litellm-model-group"] == "long"
+ assert core.settings().context_window_fallbacks == [{"gpt": ["long"]}]
+
+
+@pytest.mark.asyncio
+@CHANGESET_KINDS
+async def test_timeout_from_a_changeset_is_passed_on_the_next_call(
+ make_core: MakeCore, host: ScriptedHost, changeset: Callable[..., ChangeSet]
+) -> None:
+ core = make_core(model_list=[deployment("gpt", "a")], timeout=30)
+ await core.acompletion(model="gpt", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+
+ core.apply(changeset(timeout=12))
+ await core.acompletion(model="gpt", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+
+ assert [call.kwargs["timeout"] for call in host.calls_to("acompletion")] == [30, 12]
+ assert core.settings().as_dict()["timeout"] == 12
+
+
+@pytest.mark.asyncio
+@CHANGESET_KINDS
+async def test_model_group_alias_from_a_changeset_routes_the_next_call(
+ make_core: MakeCore, host: ScriptedHost, changeset: Callable[..., ChangeSet]
+) -> None:
+ core = make_core(model_list=[deployment("gpt", "a"), deployment("emb", "e")], model_group_alias={"fast": "gpt"})
+
+ core.apply(changeset(model_group_alias={"fast": "emb"}))
+ await core.acompletion(model="fast", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+
+ assert host.deployments_called("acompletion") == ["e"]
+ assert core.registry().aliases() == {"fast": "emb"}
+ assert core.registry().resolve("fast") == "emb"
+
+
+@pytest.mark.asyncio
+@CHANGESET_KINDS
+async def test_allowed_fails_and_cooldown_time_from_a_changeset_govern_the_next_cooldown(
+ make_core: MakeCore, changeset: Callable[..., ChangeSet]
+) -> None:
+ core = make_core(model_list=[deployment("gpt", "a"), deployment("gpt", "b")])
+ assert core.health().record_failure("a", server_error(), 500) is False
+
+ core.apply(changeset(allowed_fails=0, cooldown_time=42))
+
+ assert core.health().record_failure("b", server_error(), 500) is True
+ assert [
+ (i, entry["cooldown_time"]) for i, entry in core.health().cooldowns.get_active_cooldowns(["a", "b"], None)
+ ] == [("b", 42)]
+ assert (core.settings().allowed_fails, core.settings().cooldown_time) == (0, 42)
+
+
+@pytest.mark.asyncio
+async def test_a_deployment_cooled_down_under_applied_allowed_fails_is_not_picked_again(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = make_core(model_list=[deployment("gpt", "a"), deployment("gpt", "b")], num_retries=0)
+ core.apply(ChangeSet(assign={"allowed_fails": 0, "cooldown_time": 60}))
+ host.answer("*", _fresh(server_error))
+
+ for _ in range(2):
+ with pytest.raises(litellm.InternalServerError):
+ await core.acompletion(model="gpt", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+
+ assert sorted(host.deployments_called("acompletion")) == ["a", "b"]
+
+
+@pytest.mark.asyncio
+@CHANGESET_KINDS
+async def test_tag_filtering_switched_on_by_a_changeset_restricts_the_next_pick(
+ make_core: MakeCore, host: ScriptedHost, changeset: Callable[..., ChangeSet]
+) -> None:
+ core = make_core(model_list=[deployment("gpt", "a", tags=["paid"]), deployment("gpt", "b", tags=["free"])])
+ tagged = {"metadata": {"tags": ["paid"]}}
+ assert sorted(_ids(await core.select("gpt", dict(tagged), kind="healthy"))) == ["a", "b"] # pyright: ignore[reportCallIssue] # select takes kind
+
+ core.apply(changeset(enable_tag_filtering=True))
+
+ assert _ids(await core.select("gpt", dict(tagged), kind="healthy")) == ["a"] # pyright: ignore[reportCallIssue] # select takes kind
+ for _ in range(3):
+ await core.acompletion(model="gpt", messages=MESSAGES, metadata={"tags": ["paid"]}) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+ assert host.deployments_called("acompletion") == ["a", "a", "a"]
+ assert core.settings().enable_tag_filtering is True
+
+
+@pytest.mark.asyncio
+async def test_routing_strategy_from_settings_is_reported_and_still_routes(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = make_core(model_list=[deployment("gpt", "a")])
+ strategy = "least-busy"
+
+ core.apply(ChangeSet(settings={"routing_strategy": strategy}))
+ await core.acompletion(model="gpt", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+
+ assert core.settings().routing_strategy == strategy
+ assert core.settings().as_dict()["routing_strategy"] == strategy
+ assert host.deployments_called("acompletion") == ["a"]
+
+
+@pytest.mark.asyncio
+async def test_default_litellm_params_from_a_changeset_are_sent_on_the_next_call(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = make_core(model_list=[deployment("gpt", "a")], default_litellm_params={"temperature": 0.2})
+
+ core.apply(ChangeSet(assign={"default_litellm_params": {"temperature": 0.7}}))
+ await core.acompletion(model="gpt", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+
+ assert [call.kwargs.get("temperature") for call in host.calls_to("acompletion")] == [0.7]
+
+
+@pytest.mark.parametrize(
+ ("name", "value"),
+ [
+ ("cooldown_time", 7.5),
+ ("allowed_fails", 9),
+ ("disable_cooldowns", True),
+ ("content_policy_fallbacks", [{"gpt": ["emb"]}]),
+ ],
+)
+def test_settings_reflect_an_assigned_field(make_core: MakeCore, name: str, value: object) -> None:
+ core = make_core(model_list=_model_list())
+
+ core.apply(ChangeSet(assign={name: value}))
+
+ assert getattr(core.settings(), name) == value
+ assert core.settings().is_set(name)
+
+
+# -- what apply refuses -------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("name", ["bogus", "model_list", "_core_num_retries"])
+def test_assigning_an_unknown_setting_raises_and_applies_nothing(make_core: MakeCore, name: str) -> None:
+ core = make_core(model_list=_model_list(), num_retries=1)
+ settings_before = core.settings()
+
+ with pytest.raises(ValueError, match=name):
+ core.apply(ChangeSet(assign={"num_retries": 5, "cooldown_time": 3, name: 1}, upsert=[_deployment("gpt", "c")]))
+
+ assert core.settings().as_dict()["num_retries"] == 1
+ assert core.settings() is settings_before
+ assert not core.registry().has_deployment("c")
+
+
+def test_apply_refuses_refresh_model_info_and_applies_nothing(make_core: MakeCore) -> None:
+ core = make_core(model_list=_model_list(), num_retries=1)
+ version_before = core.registry().version
+
+ with pytest.raises(TypeError, match="aapply"):
+ core.apply(ChangeSet(refresh_model_info=True, assign={"num_retries": 5}, delete=["a"]))
+
+ assert core.settings().as_dict()["num_retries"] == 1
+ assert core.registry().version == version_before
+ assert core.registry().has_deployment("a")
+
+
+# -- the settings() snapshot --------------------------------------------------------------------
+
+
+def test_settings_snapshot_is_reused_between_reads(make_core: MakeCore) -> None:
+ core = make_core(model_list=_model_list())
+
+ assert core.settings() is core.settings()
+
+
+@CHANGESET_KINDS
+def test_applying_a_setting_replaces_the_snapshot_and_leaves_the_old_one_as_it_was(
+ make_core: MakeCore, changeset: Callable[..., ChangeSet]
+) -> None:
+ core = make_core(model_list=_model_list(), cooldown_time=5)
+ before = core.settings()
+
+ core.apply(changeset(cooldown_time=11))
+
+ after = core.settings()
+ assert after is not before
+ assert (before.cooldown_time, after.cooldown_time) == (5, 11)
+ assert core.settings() is after
+
+
+@pytest.mark.parametrize(
+ "changes",
+ [ChangeSet(upsert=[_deployment("gpt", "c")]), ChangeSet(delete=["a"])],
+ ids=["upsert", "delete"],
+)
+def test_a_changeset_without_settings_keeps_the_snapshot(make_core: MakeCore, changes: ChangeSet) -> None:
+ core = make_core(model_list=_model_list())
+ before = core.settings()
+
+ core.apply(changes)
+
+ assert core.settings() is before
+
+
+# -- a direct attribute write is a changeset ----------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("name", "value"),
+ [
+ ("num_retries", 4),
+ ("timeout", 9.0),
+ ("retry_after", 3),
+ ("allowed_fails", 1),
+ ("cooldown_time", 11.0),
+ ("fallbacks", [{"gpt": ["emb"]}]),
+ ("context_window_fallbacks", [{"gpt": ["emb"]}]),
+ ("enable_tag_filtering", True),
+ ("model_group_alias", {"fast": "emb"}),
+ ],
+)
+def test_writing_a_setting_attribute_is_the_same_as_assigning_it_in_a_changeset(
+ make_core: MakeCore, name: str, value: object
+) -> None:
+ written = make_core(model_list=_model_list())
+ applied = make_core(model_list=_model_list())
+ snapshot_before = written.settings()
+ version_before = written.registry().version
+
+ setattr(written, name, copy.deepcopy(value))
+ applied.apply(ChangeSet(assign={name: copy.deepcopy(value)}))
+
+ assert written.settings().as_dict() == applied.settings().as_dict()
+ assert written.registry().aliases() == applied.registry().aliases()
+ if written.settings().is_set(name):
+ assert getattr(written.settings(), name) == getattr(applied.settings(), name) == value
+ assert written.settings() is not snapshot_before
+ assert written.registry().version == version_before
+
+
+# -- constructor arguments ----------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("name", "value"),
+ [
+ ("model_list", _model_list()),
+ ("fallbacks", [{"gpt": ["emb"]}]),
+ ("model_group_alias", {"fast": "gpt"}),
+ ("model_group_retry_policy", {"gpt": {"RateLimitErrorRetries": 1}}),
+ ("allowed_fails_policy", {"RateLimitErrorAllowedFails": 2}),
+ ],
+)
+def test_construction_leaves_the_arguments_it_was_given_unchanged(
+ make_core: MakeCore, name: str, value: object
+) -> None:
+ given = copy.deepcopy(value)
+ arguments: dict[str, object] = {"model_list": _model_list(), name: given}
+
+ make_core(**arguments)
+
+ assert given == value
+
+
+@pytest.mark.asyncio
+async def test_mutating_the_model_list_after_construction_changes_nothing(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ model_list = _model_list()
+ core = make_core(model_list=model_list)
+ version_before = core.registry().version
+
+ model_list.append(deployment("gpt", "late"))
+ model_list[2]["model_name"] = "gpt"
+ model_list[0]["litellm_params"]["model"] = "openai/elsewhere" # pyright: ignore[reportIndexIssue] # a model_list entry
+ model_list[0]["model_info"]["id"] = "renamed" # pyright: ignore[reportIndexIssue] # a model_list entry
+ await core.aembedding(model="emb", input=["x"]) # pyright: ignore[reportAttributeAccessIssue] # an endpoint method
+
+ assert core.registry().names(kind="ids") == ["a", "b", "e"]
+ assert core.registry().names(kind="group_set") == {"gpt", "emb"}
+ assert core.registry().version == version_before
+ assert [(call.model, call.deployment_id) for call in host.calls_to("aembedding")] == [("openai/e", "e")]
+
+
+# -- retire --------------------------------------------------------------------------------------
+
+
+def _registered(host: ScriptedHost) -> Mapping[str, int]:
+ return {name: len(callbacks) for name, callbacks in host.lists.items() if callbacks}
+
+
+def test_retire_releases_every_callback_the_core_registered_on_its_host(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core = make_core(model_list=_model_list())
+ assert _registered(host) != {}
+
+ core.retire()
+
+ assert _registered(host) == {}
+
+
+def test_retire_leaves_another_cores_callbacks_registered(make_core: MakeCore, host: ScriptedHost) -> None:
+ kept = make_core(model_list=_model_list())
+ retired = make_core(model_list=[deployment("other", "o")])
+ registered_by_both = _registered(host)
+
+ retired.retire()
+
+ assert _registered(host) != {}
+ assert sum(_registered(host).values()) < sum(registered_by_both.values())
+ kept.retire()
+ assert _registered(host) == {}
diff --git a/tests/unit/test_router/core/test_retries_cooldowns.py b/tests/unit/test_router/core/test_retries_cooldowns.py
new file mode 100644
index 00000000000..004b1762240
--- /dev/null
+++ b/tests/unit/test_router/core/test_retries_cooldowns.py
@@ -0,0 +1,832 @@
+"""
+Retries and cooldowns of a router core, driven through its public surface on a ``ScriptedHost``.
+
+Backoff values carry litellm's random jitter (up to ``JITTER`` seconds), so sleeps are asserted
+inside ``[base, base + JITTER)`` rather than exactly.
+"""
+
+from collections.abc import Callable, Mapping
+from typing import Final
+
+import httpx
+import pytest
+
+import litellm
+from litellm.constants import DEFAULT_COOLDOWN_TIME_SECONDS, INITIAL_RETRY_DELAY, JITTER
+from litellm.router_api import ChangeSet
+from litellm.router_core import RouterCore
+from litellm.types.router import AllowedFailsPolicy, RetryPolicy
+from tests.unit.test_router.core.scripted_host import (
+ Call,
+ ScriptedHost,
+ auth_error,
+ bad_request,
+ content_policy,
+ context_window,
+ deployment,
+ rate_limit,
+ server_error,
+ timeout,
+)
+
+MESSAGES: Final = [{"role": "user", "content": "hi"}]
+
+MakeCore = Callable[..., RouterCore]
+ErrorBuilder = Callable[[], Exception]
+
+
+class LitellmParamsLoggingHost(ScriptedHost):
+ """``ScriptedHost`` whose failure logging carries the call's ``cooldown_time`` in ``litellm_params``, as
+ litellm's own logging does (``get_litellm_params``)."""
+
+ def _logging_kwargs(
+ self, call: Call, error: BaseException | None, response: object
+ ) -> dict[str, object]: # mutable-ok: litellm's logging kwargs
+ logged: Final = super()._logging_kwargs(call, error, response)
+ litellm_params: Final = logged["litellm_params"]
+ if isinstance(litellm_params, dict) and "cooldown_time" in call.kwargs:
+ litellm_params["cooldown_time"] = call.kwargs["cooldown_time"]
+ return logged
+
+
+@pytest.fixture
+def host() -> ScriptedHost:
+ return LitellmParamsLoggingHost()
+
+
+def fresh(builder: ErrorBuilder) -> Callable[..., Exception]:
+ """A scripted outcome that raises a new error per call, so stamps from one call never leak into the next."""
+ return lambda **_: builder()
+
+
+def rate_limited(retry_after: str | None = None) -> litellm.RateLimitError:
+ headers: Final = {"retry-after": retry_after} if retry_after is not None else {}
+ return litellm.RateLimitError(
+ message="slow down", llm_provider="openai", model="gpt-4o", response=httpx.Response(429, headers=headers)
+ )
+
+
+async def attempt(core: RouterCore, model: str = "g", **kwargs: object) -> object: # kwargs-ok: request kwargs
+ """The response, or the error the core raised."""
+ try:
+ return await core.acompletion(model=model, messages=MESSAGES, **kwargs) # pyright: ignore[reportAttributeAccessIssue] # every core serves acompletion
+ except Exception as error:
+ return error
+
+
+def cooled_down(core: RouterCore, ids: list[str]) -> dict[str, Mapping[str, object]]: # mutable-ok: a fresh dict
+ return dict(core.health().cooldowns.get_active_cooldowns(ids, None))
+
+
+def headers_of(response: object) -> Mapping[str, object]:
+ hidden: Final = getattr(response, "_hidden_params", None) or {}
+ return hidden.get("additional_headers") or {}
+
+
+def two_deployments(
+ **params: object,
+) -> list[dict[str, object]]: # kwargs-ok: litellm_params # mutable-ok: a model_list
+ return [deployment("g", "a", **params), deployment("g", "b", **params)]
+
+
+# --- how many retries ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("constructor", "request_value", "process", "expected_retries"),
+ [
+ (None, None, None, 2),
+ (3, None, None, 3),
+ (None, None, 4, 4),
+ (1, None, 4, 1),
+ (3, 1, None, 1),
+ (3, 0, None, 0),
+ (None, 2, 5, 2),
+ (0, 2, None, 2),
+ ],
+)
+@pytest.mark.asyncio
+async def test_num_retries_comes_from_request_then_constructor_then_process_then_default(
+ make_core: MakeCore,
+ host: ScriptedHost,
+ constructor: int | None,
+ request_value: int | None,
+ process: int | None,
+ expected_retries: int,
+) -> None:
+ if process is not None:
+ host.set_dynamic(num_retries=process)
+ arguments: Final = {"num_retries": constructor} if constructor is not None else {}
+ core: Final = make_core(model_list=[deployment("g", "a")], **arguments)
+ host.answer("openai/a", fresh(server_error))
+ request: Final = {"num_retries": request_value} if request_value is not None else {}
+
+ error: Final = await attempt(core, **request)
+
+ assert isinstance(error, litellm.InternalServerError)
+ assert host.deployments_called("acompletion") == ["a"] * (expected_retries + 1)
+
+
+@pytest.mark.asyncio
+async def test_process_num_retries_is_read_once_when_the_core_is_built(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")])
+ host.set_dynamic(num_retries=5)
+ host.answer("openai/a", fresh(server_error))
+
+ await attempt(core)
+
+ assert len(host.calls_to("acompletion")) == 3
+
+
+@pytest.mark.asyncio
+async def test_num_retries_applied_through_settings_changes_later_requests(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=3)
+ core.apply(ChangeSet(settings={"num_retries": 1}))
+ host.answer("openai/a", fresh(server_error))
+
+ await attempt(core)
+
+ assert len(host.calls_to("acompletion")) == 2
+
+
+# --- which errors retry -------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("builder", "expected_calls"),
+ [
+ (bad_request, 1),
+ (auth_error, 1),
+ (context_window, 1),
+ (content_policy, 1),
+ (rate_limit, 3),
+ (server_error, 3),
+ (timeout, 3),
+ ],
+)
+@pytest.mark.asyncio
+async def test_lone_deployment_retries_only_retryable_errors(
+ make_core: MakeCore, host: ScriptedHost, builder: ErrorBuilder, expected_calls: int
+) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=2)
+ host.answer("openai/a", fresh(builder))
+
+ error: Final = await attempt(core)
+
+ assert type(error) is type(builder())
+ assert len(host.calls_to("acompletion")) == expected_calls
+
+
+@pytest.mark.parametrize(
+ ("builder", "expected_calls"),
+ [(bad_request, 1), (context_window, 1), (content_policy, 1), (server_error, 3), (timeout, 3)],
+)
+@pytest.mark.asyncio
+async def test_two_deployment_group_retries_only_retryable_errors(
+ make_core: MakeCore, host: ScriptedHost, builder: ErrorBuilder, expected_calls: int
+) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=2)
+ host.answer("*", fresh(builder))
+
+ error: Final = await attempt(core)
+
+ assert type(error) is type(builder())
+ assert len(host.calls_to("acompletion")) == expected_calls
+
+
+@pytest.mark.asyncio
+async def test_auth_error_retries_on_the_other_deployment_of_the_group(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=2)
+ host.answer("*", fresh(auth_error))
+
+ error: Final = await attempt(core)
+
+ assert isinstance(error, litellm.AuthenticationError)
+ assert sorted(host.deployments_called("acompletion")) == ["a", "b"]
+
+
+@pytest.mark.asyncio
+async def test_rate_limits_stop_retrying_once_every_deployment_is_cooling_down(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=5)
+ host.answer("*", fresh(rate_limit))
+
+ error: Final = await attempt(core)
+
+ assert isinstance(error, litellm.RateLimitError)
+ assert sorted(host.deployments_called("acompletion")) == ["a", "b"]
+ assert set(cooled_down(core, ["a", "b"])) == {"a", "b"}
+
+
+@pytest.mark.parametrize("builder", [bad_request, auth_error, context_window, content_policy])
+@pytest.mark.asyncio
+async def test_an_error_raised_without_retrying_carries_no_retry_counts(
+ make_core: MakeCore, host: ScriptedHost, builder: ErrorBuilder
+) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=2)
+ host.answer("openai/a", fresh(builder))
+
+ error: Final = await attempt(core)
+
+ assert getattr(error, "num_retries", None) is None
+ assert getattr(error, "max_retries", None) is None
+
+
+@pytest.mark.parametrize("builder", [rate_limit, server_error, timeout])
+@pytest.mark.parametrize("num_retries", [1, 3])
+@pytest.mark.asyncio
+async def test_exhausted_retries_stamp_num_retries_and_max_retries_on_the_final_error(
+ make_core: MakeCore, host: ScriptedHost, builder: ErrorBuilder, num_retries: int
+) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=num_retries)
+ host.answer("openai/a", fresh(builder))
+
+ error: Final = await attempt(core)
+
+ assert getattr(error, "num_retries", None) == num_retries
+ assert getattr(error, "max_retries", None) == num_retries
+ assert f"LiteLLM Retried: {num_retries} times" in str(error)
+
+
+@pytest.mark.asyncio
+async def test_the_final_error_is_the_latest_one_not_the_first(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=2)
+ first: Final = server_error()
+ last: Final = timeout()
+ host.answer("openai/a", first, fresh(server_error), last)
+
+ error: Final = await attempt(core)
+
+ assert error is host.raised[-1]
+ assert type(error) is type(last)
+
+
+# --- retry policies -----------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("field", "builder", "retries"),
+ [
+ ("BadRequestErrorRetries", bad_request, 2),
+ ("BadRequestErrorRetries", context_window, 1),
+ ("ContentPolicyViolationErrorRetries", content_policy, 1),
+ ("RateLimitErrorRetries", rate_limit, 0),
+ ("TimeoutErrorRetries", timeout, 1),
+ ("InternalServerErrorRetries", server_error, 4),
+ ("DefaultRetries", server_error, 1),
+ ],
+)
+@pytest.mark.asyncio
+async def test_retry_policy_sets_retries_per_error_type(
+ make_core: MakeCore, host: ScriptedHost, field: str, builder: ErrorBuilder, retries: int
+) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a")], num_retries=3, retry_policy=RetryPolicy(**{field: retries})
+ )
+ host.answer("openai/a", fresh(builder))
+
+ error: Final = await attempt(core)
+
+ assert type(error) is type(builder())
+ assert len(host.calls_to("acompletion")) == retries + 1
+
+
+@pytest.mark.asyncio
+async def test_retry_policy_given_as_a_dict_works_like_a_retry_policy(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a")], num_retries=3, retry_policy={"BadRequestErrorRetries": 1}
+ )
+ host.answer("openai/a", fresh(bad_request))
+
+ await attempt(core)
+
+ assert len(host.calls_to("acompletion")) == 2
+
+
+@pytest.mark.asyncio
+async def test_retry_policy_for_one_error_type_leaves_others_to_num_retries(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a")], num_retries=1, retry_policy=RetryPolicy(RateLimitErrorRetries=4)
+ )
+ host.answer("openai/a", fresh(timeout))
+
+ await attempt(core)
+
+ assert len(host.calls_to("acompletion")) == 2
+
+
+@pytest.mark.asyncio
+async def test_model_group_retry_policy_applies_only_to_its_group(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a"), deployment("h", "c")],
+ num_retries=2,
+ model_group_retry_policy={"g": RetryPolicy(InternalServerErrorRetries=0)},
+ )
+ host.answer("*", fresh(server_error))
+
+ await attempt(core, model="g")
+ await attempt(core, model="h")
+
+ assert host.deployments_called("acompletion") == ["a", "c", "c", "c"]
+
+
+@pytest.mark.asyncio
+async def test_model_group_retry_policy_wins_over_retry_policy(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a")],
+ retry_policy=RetryPolicy(InternalServerErrorRetries=4),
+ model_group_retry_policy={"g": RetryPolicy(InternalServerErrorRetries=1)},
+ )
+ host.answer("openai/a", fresh(server_error))
+
+ await attempt(core)
+
+ assert len(host.calls_to("acompletion")) == 2
+
+
+@pytest.mark.parametrize(("request_value", "expected_calls"), [(4, 2), (0, 1)])
+@pytest.mark.asyncio
+async def test_retry_policy_wins_over_request_num_retries_unless_the_request_opts_out(
+ make_core: MakeCore, host: ScriptedHost, request_value: int, expected_calls: int
+) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], retry_policy=RetryPolicy(InternalServerErrorRetries=1))
+ host.answer("openai/a", fresh(server_error))
+
+ await attempt(core, num_retries=request_value)
+
+ assert len(host.calls_to("acompletion")) == expected_calls
+
+
+# --- per-deployment num_retries -----------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("deployment_value", "request_kwargs", "expected_calls"),
+ [
+ (1, {}, 2),
+ ("1", {}, 2),
+ (0, {}, 1),
+ (4, {}, 5),
+ (4, {"num_retries": 0}, 1),
+ (0, {"num_retries": 2}, 3),
+ ],
+)
+@pytest.mark.asyncio
+async def test_deployment_num_retries_beats_the_router_but_not_the_request(
+ make_core: MakeCore,
+ host: ScriptedHost,
+ deployment_value: object,
+ request_kwargs: dict[str, object],
+ expected_calls: int,
+) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a", num_retries=deployment_value)], num_retries=3)
+ host.answer("openai/a", fresh(server_error))
+
+ error: Final = await attempt(core, **request_kwargs)
+
+ assert isinstance(error, litellm.InternalServerError)
+ assert len(host.calls_to("acompletion")) == expected_calls
+
+
+# --- backoff ------------------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_lone_deployment_backs_off_exponentially(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=3)
+ host.answer("openai/a", fresh(server_error))
+
+ await attempt(core)
+
+ bases: Final = [INITIAL_RETRY_DELAY * 2**n for n in range(3)]
+ assert len(host.sleeps) >= 3
+ assert all(base <= slept < base + JITTER for base, slept in zip(bases, host.sleeps))
+
+
+@pytest.mark.asyncio
+async def test_retry_after_setting_is_the_minimum_backoff(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=2, retry_after=3)
+ host.answer("openai/a", fresh(server_error))
+
+ await attempt(core)
+
+ assert len(host.sleeps) >= 2
+ assert all(3 <= slept < 3 + JITTER for slept in host.sleeps[:2])
+
+
+@pytest.mark.asyncio
+async def test_lone_deployment_waits_the_retry_after_header(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=2)
+ host.answer("openai/a", lambda **_: rate_limited(retry_after="7"))
+
+ await attempt(core)
+
+ assert len(host.sleeps) >= 2
+ assert all(7 <= slept < 7 + JITTER for slept in host.sleeps[:2])
+
+
+@pytest.mark.asyncio
+async def test_lone_deployment_ignores_a_retry_after_header_over_a_minute(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=2)
+ host.answer("openai/a", lambda **_: rate_limited(retry_after="120"))
+
+ await attempt(core)
+
+ assert INITIAL_RETRY_DELAY <= host.sleeps[0] < INITIAL_RETRY_DELAY + JITTER
+
+
+@pytest.mark.parametrize("failure", [fresh(server_error), fresh(timeout), lambda **_: rate_limited(retry_after="7")])
+@pytest.mark.asyncio
+async def test_a_group_with_siblings_retries_immediately(
+ make_core: MakeCore, host: ScriptedHost, failure: Callable[..., Exception]
+) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=2)
+ host.answer("*", failure, None)
+
+ response: Final = await attempt(core)
+
+ assert not isinstance(response, Exception)
+ assert host.sleeps == [0]
+
+
+@pytest.mark.asyncio
+async def test_no_sleep_when_the_error_is_not_retried(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=3)
+ host.answer("openai/a", fresh(bad_request))
+
+ await attempt(core)
+
+ assert host.sleeps == []
+
+
+# --- retry headers on success -------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("failures_first", [1, 2, 3])
+@pytest.mark.asyncio
+async def test_success_after_retries_reports_attempted_and_max_retries(
+ make_core: MakeCore, host: ScriptedHost, failures_first: int
+) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=3)
+ host.answer("openai/a", *[fresh(server_error)] * failures_first, None)
+
+ response: Final = await attempt(core)
+
+ headers: Final = headers_of(response)
+ assert headers["x-litellm-attempted-retries"] == failures_first
+ assert headers["x-litellm-max-retries"] == 3
+ assert headers["x-litellm-model-group"] == "g"
+
+
+@pytest.mark.asyncio
+async def test_first_try_success_reports_zero_retries_and_no_max(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=3)
+
+ response: Final = await attempt(core)
+
+ headers: Final = headers_of(response)
+ assert headers["x-litellm-attempted-retries"] == 0
+ assert "x-litellm-max-retries" not in headers
+
+
+@pytest.mark.asyncio
+async def test_success_headers_report_the_retry_policy_budget(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a")], num_retries=1, retry_policy=RetryPolicy(InternalServerErrorRetries=4)
+ )
+ host.answer("openai/a", fresh(server_error), fresh(server_error), None)
+
+ response: Final = await attempt(core)
+
+ assert headers_of(response)["x-litellm-attempted-retries"] == 2
+ assert headers_of(response)["x-litellm-max-retries"] == 4
+
+
+# --- allowed fails and cooldowns ----------------------------------------------------------------
+
+
+@pytest.mark.parametrize("allowed_fails", [0, 1, 2])
+@pytest.mark.asyncio
+async def test_a_deployment_cools_down_once_its_failures_pass_allowed_fails(
+ make_core: MakeCore, host: ScriptedHost, allowed_fails: int
+) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=0, allowed_fails=allowed_fails)
+ host.answer("*", fresh(server_error))
+
+ for _ in range(2 * allowed_fails + 2):
+ await attempt(core)
+
+ called: Final = host.deployments_called("acompletion")
+ cooled: Final = cooled_down(core, ["a", "b"])
+ assert cooled
+ assert {d for d in ("a", "b") if called.count(d) > allowed_fails} == set(cooled)
+
+
+@pytest.mark.asyncio
+async def test_process_allowed_fails_is_the_default(make_core: MakeCore, host: ScriptedHost) -> None:
+ host.set_dynamic(allowed_fails=0)
+ core: Final = make_core(model_list=two_deployments(), num_retries=0)
+ host.answer("*", fresh(server_error))
+
+ await attempt(core)
+
+ assert core.settings().allowed_fails == 0
+ assert list(cooled_down(core, ["a", "b"])) == host.deployments_called("acompletion")
+
+
+@pytest.mark.asyncio
+async def test_a_cooled_deployment_is_skipped_by_later_picks(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=0, allowed_fails=0)
+ host.answer("*", fresh(server_error))
+
+ await attempt(core)
+ first: Final = host.deployments_called("acompletion")[0]
+ healthy: Final = await core.select("g", {}, kind="healthy") # pyright: ignore[reportAttributeAccessIssue] # every core serves select
+ await attempt(core)
+
+ assert [d["model_info"]["id"] for d in healthy] == [{"a": "b", "b": "a"}[first]]
+ assert host.deployments_called("acompletion")[1] != first
+
+
+@pytest.mark.asyncio
+async def test_a_group_with_every_deployment_cooling_down_raises_without_calling(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=0, allowed_fails=0)
+ host.answer("*", fresh(server_error))
+ await attempt(core)
+ await attempt(core)
+
+ error: Final = await attempt(core)
+
+ assert isinstance(error, litellm.RouterRateLimitError)
+ assert len(host.calls_to("acompletion")) == 2
+
+
+@pytest.mark.asyncio
+async def test_rate_limit_cools_down_a_deployment_with_siblings_at_once(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=0)
+ host.answer("*", fresh(rate_limit))
+
+ await attempt(core)
+
+ assert list(cooled_down(core, ["a", "b"])) == host.deployments_called("acompletion")
+
+
+@pytest.mark.parametrize(
+ "policy",
+ [AllowedFailsPolicy(RateLimitErrorAllowedFails=2), {"RateLimitErrorAllowedFails": 2}],
+ ids=["AllowedFailsPolicy", "dict"],
+)
+@pytest.mark.asyncio
+async def test_allowed_fails_policy_lets_rate_limits_through_until_its_count(
+ make_core: MakeCore, host: ScriptedHost, policy: object
+) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=0, allowed_fails_policy=policy)
+ host.answer("*", fresh(rate_limit))
+
+ await attempt(core)
+
+ assert cooled_down(core, ["a", "b"]) == {}
+
+
+@pytest.mark.asyncio
+async def test_allowed_fails_policy_of_zero_cools_down_on_the_first_failure_of_that_type(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core: Final = make_core(
+ model_list=two_deployments(),
+ num_retries=0,
+ allowed_fails=5,
+ allowed_fails_policy=AllowedFailsPolicy(TimeoutErrorAllowedFails=0),
+ )
+ host.answer("*", fresh(server_error), fresh(timeout))
+
+ await attempt(core)
+ after_server_error: Final = cooled_down(core, ["a", "b"])
+ await attempt(core)
+
+ assert after_server_error == {}
+ assert list(cooled_down(core, ["a", "b"])) == host.deployments_called("acompletion")[1:]
+
+
+@pytest.mark.asyncio
+async def test_deployment_allowed_fails_policy_in_model_info_cools_down_on_a_bad_request(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core: Final = make_core(
+ model_list=two_deployments(model_info={"allowed_fails_policy": {"BadRequestErrorAllowedFails": 0}}),
+ num_retries=0,
+ )
+ host.answer("*", fresh(bad_request))
+
+ await attempt(core)
+
+ assert list(cooled_down(core, ["a", "b"])) == host.deployments_called("acompletion")
+
+
+@pytest.mark.asyncio
+async def test_bad_request_does_not_cool_down_without_a_deployment_policy(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=0, allowed_fails=0)
+ host.answer("*", fresh(bad_request))
+
+ await attempt(core)
+
+ assert cooled_down(core, ["a", "b"]) == {}
+
+
+# --- cooldown time ------------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("arguments", "params", "failure", "expected_seconds"),
+ [
+ ({}, {}, fresh(rate_limit), DEFAULT_COOLDOWN_TIME_SECONDS),
+ ({"cooldown_time": 42}, {}, fresh(rate_limit), 42),
+ ({}, {"cooldown_time": 30}, fresh(rate_limit), 30),
+ ({}, {"model_info": {"cooldown_time": 30}}, fresh(rate_limit), 30),
+ ({}, {}, lambda **_: rate_limited(retry_after="9"), 9),
+ ({"cooldown_time": 42}, {}, lambda **_: rate_limited(retry_after="9"), 9),
+ ({}, {"cooldown_time": 30}, lambda **_: rate_limited(retry_after="9"), 30),
+ ],
+ ids=[
+ "default",
+ "constructor",
+ "litellm_params",
+ "model_info",
+ "retry-after",
+ "header-over-router",
+ "deployment-over-header",
+ ],
+)
+@pytest.mark.asyncio
+async def test_cooldown_time_comes_from_deployment_then_retry_after_then_router(
+ make_core: MakeCore,
+ host: ScriptedHost,
+ arguments: dict[str, object],
+ params: dict[str, object],
+ failure: Callable[..., Exception],
+ expected_seconds: float,
+) -> None:
+ core: Final = make_core(model_list=two_deployments(**params), num_retries=0, **arguments)
+ host.answer("*", failure)
+
+ await attempt(core)
+
+ (entry,) = cooled_down(core, ["a", "b"]).values()
+ assert entry["cooldown_time"] == expected_seconds
+
+
+@pytest.mark.parametrize("change", [{"settings": {"cooldown_time": 20}}, {"assign": {"cooldown_time": 20}}])
+@pytest.mark.asyncio
+async def test_cooldown_time_applied_at_runtime_is_used_for_later_cooldowns(
+ make_core: MakeCore, host: ScriptedHost, change: dict[str, Mapping[str, object]]
+) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=0)
+ core.apply(ChangeSet(**change)) # pyright: ignore[reportArgumentType] # ChangeSet's mapping fields
+ host.answer("*", fresh(rate_limit))
+
+ await attempt(core)
+
+ assert core.settings().cooldown_time == 20
+ (entry,) = cooled_down(core, ["a", "b"]).values()
+ assert entry["cooldown_time"] == 20
+
+
+@pytest.mark.asyncio
+async def test_a_deployment_cooldown_time_of_zero_skips_the_cooldown(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=two_deployments(model_info={"cooldown_time": 0}), num_retries=0)
+ host.answer("*", fresh(rate_limit))
+
+ await attempt(core)
+
+ assert cooled_down(core, ["a", "b"]) == {}
+
+
+# --- disable_cooldowns --------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("failure", [fresh(rate_limit), fresh(server_error)])
+@pytest.mark.asyncio
+async def test_disable_cooldowns_keeps_failing_deployments_routable(
+ make_core: MakeCore, host: ScriptedHost, failure: Callable[..., Exception]
+) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=0, allowed_fails=0, disable_cooldowns=True)
+ host.answer("*", failure)
+
+ for _ in range(4):
+ await attempt(core)
+
+ assert cooled_down(core, ["a", "b"]) == {}
+ assert len(host.calls_to("acompletion")) == 4
+
+
+@pytest.mark.asyncio
+async def test_disable_cooldowns_assigned_at_runtime_stops_cooldowns(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=0)
+ core.apply(ChangeSet(assign={"disable_cooldowns": True}))
+ host.answer("*", fresh(rate_limit))
+
+ await attempt(core)
+
+ assert core.settings().disable_cooldowns is True
+ assert cooled_down(core, ["a", "b"]) == {}
+
+
+# --- single-deployment groups -------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("arguments", [{}, {"allowed_fails": 0}], ids=["default", "allowed_fails=0"])
+@pytest.mark.parametrize("builder", [rate_limit, server_error, timeout])
+@pytest.mark.asyncio
+async def test_a_single_deployment_group_is_not_cooled_down(
+ make_core: MakeCore, host: ScriptedHost, arguments: dict[str, object], builder: ErrorBuilder
+) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=0, **arguments)
+ host.answer("openai/a", fresh(builder))
+
+ for _ in range(3):
+ await attempt(core)
+
+ assert cooled_down(core, ["a"]) == {}
+ assert host.deployments_called("acompletion") == ["a", "a", "a"]
+
+
+@pytest.mark.asyncio
+async def test_an_allowed_fails_policy_naming_the_error_cools_down_a_single_deployment(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a")],
+ num_retries=0,
+ allowed_fails_policy=AllowedFailsPolicy(InternalServerErrorAllowedFails=0),
+ )
+ host.answer("openai/a", fresh(server_error))
+
+ await attempt(core)
+ error: Final = await attempt(core)
+
+ assert set(cooled_down(core, ["a"])) == {"a"}
+ assert isinstance(error, litellm.RouterRateLimitError)
+ assert host.deployments_called("acompletion") == ["a"]
+
+
+# --- health() -----------------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_health_cooldown_entry_describes_the_failure(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=two_deployments(), num_retries=0)
+ host.answer("*", fresh(rate_limit))
+
+ await attempt(core)
+
+ (entry,) = cooled_down(core, ["a", "b"]).values()
+ assert entry["status_code"] == "429"
+ assert "slow down" in str(entry["exception_received"])
+
+
+@pytest.mark.parametrize("failures", [1, 3])
+@pytest.mark.asyncio
+async def test_health_counts_failures_this_minute(make_core: MakeCore, host: ScriptedHost, failures: int) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=0)
+ host.answer("openai/a", fresh(server_error))
+
+ for _ in range(failures):
+ await attempt(core)
+
+ assert core.health().failures_this_minute("a") == failures
+ assert core.health().failures_this_minute("missing") == 0
+
+
+@pytest.mark.asyncio
+async def test_health_counts_every_retried_attempt_as_a_failure(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], num_retries=2)
+ host.answer("openai/a", fresh(server_error))
+
+ await attempt(core)
+
+ assert core.health().failures_this_minute("a") == 3
+
+
+@pytest.mark.asyncio
+async def test_health_record_failure_cools_down_past_allowed_fails(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=two_deployments(), allowed_fails=1)
+
+ first: Final = core.health().record_failure("a", server_error(), 500)
+ second: Final = core.health().record_failure("a", server_error(), 500)
+
+ assert (first, second) == (False, True)
+ assert set(cooled_down(core, ["a", "b"])) == {"a"}
+ assert core.health().failures_this_minute("a") == 2
diff --git a/tests/unit/test_router/core/test_selection.py b/tests/unit/test_router/core/test_selection.py
new file mode 100644
index 00000000000..4b4c320c428
--- /dev/null
+++ b/tests/unit/test_router/core/test_selection.py
@@ -0,0 +1,624 @@
+"""
+Deployment selection on a router core: which deployment ``select`` picks and which one an endpoint
+call reaches, across groups, aliases, wildcard routes, deployment ids, teams, weights, order,
+routing strategies and cooldowns.
+"""
+
+import re
+from collections.abc import Callable, Mapping
+from typing import Final
+
+import openai
+import pytest
+
+import litellm
+from litellm.router_core import RouterCore
+from litellm.types.router import RouterRateLimitError
+from tests.unit.test_router.core.scripted_host import (
+ ScriptedHost,
+ auth_error,
+ bad_request,
+ context_window,
+ deployment,
+ rate_limit,
+ server_error,
+ timeout,
+)
+
+MESSAGES: Final = [{"role": "user", "content": "hello there, router"}]
+PICKS: Final = 20
+
+MakeCore = Callable[..., RouterCore]
+
+
+async def _picked_ids(
+ core: RouterCore, model: str, times: int = PICKS, **select_arguments: object
+) -> set[str]: # kwargs-ok: select's keyword arguments
+ picked: Final[set[str]] = set()
+ for _ in range(times):
+ chosen = await core.select(model, **select_arguments) # pyright: ignore[reportCallIssue] # PythonCore's select takes keyword options
+ assert isinstance(chosen, Mapping)
+ picked.add(str(chosen["model_info"]["id"]))
+ return picked
+
+
+async def _fail_through(core: RouterCore, deployment_id: str, times: int = 1) -> None:
+ for _ in range(times):
+ with pytest.raises(openai.APIError):
+ await core.acompletion(model=deployment_id, messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue] # endpoint method
+
+
+# --- picking within a model group ----------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(("model", "expected"), [("g1", "a"), ("g2", "b"), ("g3", "c")])
+async def test_select_returns_the_only_deployment_of_the_requested_group(
+ make_core: MakeCore, model: str, expected: str
+) -> None:
+ core: Final = make_core(model_list=[deployment("g1", "a"), deployment("g2", "b"), deployment("g3", "c")])
+ chosen: Final = await core.select(model)
+ assert chosen["model_info"]["id"] == expected # pyright: ignore[reportIndexIssue]
+ assert chosen["model_name"] == model # pyright: ignore[reportIndexIssue]
+ assert chosen["litellm_params"]["model"] == f"openai/{expected}" # pyright: ignore[reportIndexIssue]
+
+
+@pytest.mark.asyncio
+async def test_select_only_picks_deployments_of_the_requested_group(make_core: MakeCore) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a"), deployment("g", "b"), deployment("other", "c"), deployment("other", "d")]
+ )
+ assert await _picked_ids(core, "g") <= {"a", "b"}
+ assert await _picked_ids(core, "other") <= {"c", "d"}
+
+
+@pytest.mark.asyncio
+async def test_select_does_not_call_the_provider(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")])
+ await core.select("g")
+ assert host.calls == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("op", "request_payload"),
+ [
+ ("acompletion", {"messages": MESSAGES}),
+ ("atext_completion", {"prompt": "hi"}),
+ ("aembedding", {"input": "hi"}),
+ ],
+)
+async def test_async_endpoint_calls_the_deployment_of_the_requested_group(
+ make_core: MakeCore, host: ScriptedHost, op: str, request_payload: dict[str, object]
+) -> None:
+ core: Final = make_core(model_list=[deployment("g1", "a"), deployment("g2", "b")])
+ await core.call(op, {"model": "g2", **request_payload})
+ assert host.deployments_called(op) == ["b"]
+ assert host.calls[-1].model == "openai/b"
+
+
+@pytest.mark.parametrize(
+ ("op", "request_payload"), [("completion", {"messages": MESSAGES}), ("embedding", {"input": "hi"})]
+)
+def test_sync_endpoint_calls_the_deployment_of_the_requested_group(
+ make_core: MakeCore, host: ScriptedHost, op: str, request_payload: dict[str, object]
+) -> None:
+ core: Final = make_core(model_list=[deployment("g1", "a"), deployment("g2", "b")])
+ getattr(core, op)(model="g1", **request_payload)
+ assert host.deployments_called(op) == ["a"]
+
+
+@pytest.mark.asyncio
+async def test_completion_reports_the_requested_group_in_its_headers(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("g1", "a"), deployment("g2", "b")])
+ response: Final = await core.acompletion(model="g2", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue]
+ assert response._hidden_params["additional_headers"]["x-litellm-model-group"] == "g2"
+ assert host.calls[-1].model_group == "g2"
+
+
+# --- model_group_alias ---------------------------------------------------------------------
+
+ALIASES: Final = {
+ "plain": "g",
+ "visible": {"model": "g", "hidden": False},
+ "hidden": {"model": "g", "hidden": True},
+}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("alias", sorted(ALIASES))
+async def test_select_resolves_an_alias_to_its_target_group(make_core: MakeCore, alias: str) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a"), deployment("other", "b")], model_group_alias=dict(ALIASES)
+ )
+ chosen: Final = await core.select(alias)
+ assert chosen["model_info"]["id"] == "a" # pyright: ignore[reportIndexIssue]
+ assert chosen["model_name"] == "g" # pyright: ignore[reportIndexIssue]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("alias", sorted(ALIASES))
+async def test_completion_through_an_alias_calls_the_target_group(
+ make_core: MakeCore, host: ScriptedHost, alias: str
+) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a"), deployment("other", "b")], model_group_alias=dict(ALIASES)
+ )
+ await core.acompletion(model=alias, messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue]
+ assert host.deployments_called("acompletion") == ["a"]
+
+
+@pytest.mark.parametrize(("alias", "target"), [("plain", "g"), ("visible", "g"), ("hidden", "g"), ("g", None)])
+def test_registry_resolves_alias_targets(make_core: MakeCore, alias: str, target: str | None) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], model_group_alias=dict(ALIASES))
+ assert core.registry().resolve(alias, kind="alias") == target
+
+
+def test_hidden_alias_is_left_out_of_the_routable_names(make_core: MakeCore) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], model_group_alias=dict(ALIASES))
+ assert sorted(core.registry().names(kind="groups")) == ["g", "plain", "visible"]
+
+
+@pytest.mark.asyncio
+async def test_alias_to_an_unknown_group_fails_naming_the_target(make_core: MakeCore) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], model_group_alias={"dangling": "missing"})
+ with pytest.raises(
+ litellm.BadRequestError, match=re.escape("You passed in model=missing. There are no healthy deployments")
+ ):
+ await core.select("dangling")
+
+
+# --- wildcard / pattern groups ---------------------------------------------------------------
+
+WILDCARD_MODEL_LIST: Final = [
+ deployment("*", "catch_all", model="openai/*"),
+ deployment("anthropic/*", "anthropic_any", model="anthropic/*"),
+ deployment("openai/gpt-4o", "exact"),
+ deployment("openai/gpt-*", "gpt_any", model="openai/gpt-*"),
+]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("requested", "expected_id", "expected_group", "expected_model"),
+ [
+ ("openai/gpt-4o", "exact", "openai/gpt-4o", "openai/exact"),
+ ("openai/gpt-4o-mini", "gpt_any", "openai/gpt-*", "openai/gpt-4o-mini"),
+ ("anthropic/claude-x", "anthropic_any", "anthropic/*", "anthropic/claude-x"),
+ ("gpt-5", "catch_all", "*", "openai/gpt-5"),
+ ("whatever/foo", "catch_all", "*", "openai/whatever/foo"),
+ ],
+)
+async def test_select_prefers_an_exact_group_then_the_most_specific_pattern(
+ make_core: MakeCore, requested: str, expected_id: str, expected_group: str, expected_model: str
+) -> None:
+ core: Final = make_core(model_list=list(WILDCARD_MODEL_LIST))
+ chosen: Final = await core.select(requested)
+ assert chosen["model_info"]["id"] == expected_id # pyright: ignore[reportIndexIssue]
+ assert chosen["model_name"] == expected_group # pyright: ignore[reportIndexIssue]
+ assert chosen["litellm_params"]["model"] == expected_model # pyright: ignore[reportIndexIssue]
+
+
+@pytest.mark.asyncio
+async def test_completion_through_a_wildcard_calls_the_requested_model(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("openai/*", "wild", model="openai/*")])
+ response: Final = await core.acompletion(model="openai/gpt-x", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue]
+ assert host.deployments_called("acompletion") == ["wild"]
+ assert host.calls[-1].model == "openai/gpt-x"
+ assert response._hidden_params["additional_headers"]["x-litellm-model-group"] == "openai/gpt-x"
+
+
+@pytest.mark.asyncio
+async def test_a_pattern_that_does_not_match_is_an_unknown_model(make_core: MakeCore) -> None:
+ core: Final = make_core(model_list=[deployment("anthropic/*", "anthropic_any", model="anthropic/*")])
+ with pytest.raises(litellm.BadRequestError, match="model=openai/gpt-4o"):
+ await core.select("openai/gpt-4o")
+
+
+# --- selecting by deployment id or litellm model --------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("deployment_id", ["a", "b", "c"])
+async def test_select_by_deployment_id_returns_that_deployment(make_core: MakeCore, deployment_id: str) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a"), deployment("g", "b"), deployment("h", "c")])
+ assert await _picked_ids(core, deployment_id) == {deployment_id}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("deployment_id", ["a", "b"])
+async def test_completion_by_deployment_id_calls_that_deployment(
+ make_core: MakeCore, host: ScriptedHost, deployment_id: str
+) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a"), deployment("g", "b")])
+ for _ in range(5):
+ await core.acompletion(model=deployment_id, messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue]
+ assert host.deployments_called("acompletion") == [deployment_id] * 5
+
+
+@pytest.mark.asyncio
+async def test_select_by_litellm_model_name_finds_the_deployment(make_core: MakeCore) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a"), deployment("g", "b")])
+ assert await _picked_ids(core, "openai/b") == {"b"}
+
+
+@pytest.mark.asyncio
+async def test_specific_deployment_picks_by_litellm_model_across_groups(make_core: MakeCore) -> None:
+ core: Final = make_core(
+ model_list=[
+ deployment("g1", "c", model="openai/shared"),
+ deployment("g2", "d", model="openai/shared"),
+ deployment("g3", "e"),
+ ]
+ )
+ assert await _picked_ids(core, "openai/shared", specific_deployment=True) <= {"c", "d"}
+ assert await _picked_ids(core, "openai/e", specific_deployment=True) == {"e"}
+
+
+@pytest.mark.asyncio
+async def test_completion_with_specific_deployment_calls_the_named_litellm_model(
+ make_core: MakeCore, host: ScriptedHost
+) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a"), deployment("g", "b")])
+ for _ in range(5):
+ await core.acompletion(model="openai/b", messages=MESSAGES, specific_deployment=True) # pyright: ignore[reportAttributeAccessIssue]
+ assert host.deployments_called("acompletion") == ["b"] * 5
+
+
+# --- unknown models ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("op", "request_payload"),
+ [
+ ("acompletion", {"messages": MESSAGES}),
+ ("aembedding", {"input": "hi"}),
+ ("atext_completion", {"prompt": "hi"}),
+ ("completion", {"messages": MESSAGES}),
+ ("embedding", {"input": "hi"}),
+ ],
+)
+async def test_unknown_model_raises_bad_request_naming_the_model(
+ make_core: MakeCore, host: ScriptedHost, op: str, request_payload: dict[str, object]
+) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")])
+ with pytest.raises(litellm.BadRequestError) as raised:
+ await core.call(op, {"model": "nope", **request_payload})
+ assert (
+ str(raised.value)
+ == "litellm.BadRequestError: You passed in model=nope. There are no healthy deployments for this model"
+ )
+ assert raised.value.status_code == 400
+ assert host.calls == []
+
+
+@pytest.mark.asyncio
+async def test_select_of_an_unknown_model_raises_bad_request(make_core: MakeCore) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")])
+ with pytest.raises(litellm.BadRequestError) as raised:
+ await core.select("nope")
+ assert raised.value.status_code == 400
+ assert raised.value.model == "nope"
+ assert "There are no healthy deployments for this model" in str(raised.value)
+
+
+# --- team-scoped deployments --------------------------------------------------------------------
+
+TEAM_MODEL_LIST: Final = [
+ deployment("g", "public"),
+ deployment("team-x-group", "team_x", model_info={"team_id": "tx", "team_public_model_name": "g"}),
+ deployment("team-y-group", "team_y", model_info={"team_id": "ty", "team_public_model_name": "g"}),
+]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("metadata", "expected"),
+ [
+ ({}, "public"),
+ ({"user_api_key_team_id": "tx"}, "team_x"),
+ ({"user_api_key_team_id": "ty"}, "team_y"),
+ ({"user_api_key_team_id": "unknown-team"}, "public"),
+ ],
+)
+async def test_select_serves_a_team_its_own_deployment_under_the_public_name(
+ make_core: MakeCore, metadata: dict[str, str], expected: str
+) -> None:
+ core: Final = make_core(model_list=list(TEAM_MODEL_LIST))
+ assert await _picked_ids(core, "g", request_kwargs={"metadata": metadata}) == {expected}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(("team_id", "expected"), [("tx", "team_x"), ("ty", "team_y"), ("tz", "public")])
+async def test_completion_for_a_team_calls_its_deployment_under_the_public_group(
+ make_core: MakeCore, host: ScriptedHost, team_id: str, expected: str
+) -> None:
+ core: Final = make_core(model_list=list(TEAM_MODEL_LIST))
+ await core.acompletion(model="g", messages=MESSAGES, metadata={"user_api_key_team_id": team_id}) # pyright: ignore[reportAttributeAccessIssue]
+ assert host.deployments_called("acompletion") == [expected]
+ assert host.calls[-1].model_group == "g"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("metadata", [{}, {"user_api_key_team_id": "other"}])
+async def test_team_only_public_name_is_unknown_to_other_callers(make_core: MakeCore, metadata: dict[str, str]) -> None:
+ core: Final = make_core(
+ model_list=[deployment("team-x-group", "team_x", model_info={"team_id": "tx", "team_public_model_name": "pub"})]
+ )
+ with pytest.raises(litellm.BadRequestError, match="model=pub"):
+ await core.select("pub", {"metadata": metadata})
+ assert await _picked_ids(core, "pub", request_kwargs={"metadata": {"user_api_key_team_id": "tx"}}) == {"team_x"}
+
+
+@pytest.mark.parametrize(("team_id", "names"), [(None, []), ("tx", ["pub"])])
+def test_team_public_names_are_routable_only_for_their_team(
+ make_core: MakeCore, team_id: str | None, names: list[str]
+) -> None:
+ core: Final = make_core(
+ model_list=[deployment("team-x-group", "team_x", model_info={"team_id": "tx", "team_public_model_name": "pub"})]
+ )
+ assert list(core.registry().names(kind="groups", team_id=team_id)) == names
+
+
+# --- weights, rpm and tpm under simple-shuffle -----------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("metric", ["weight", "rpm", "tpm"])
+async def test_simple_shuffle_never_picks_a_zero_weighted_deployment_beside_a_positive_one(
+ make_core: MakeCore, metric: str
+) -> None:
+ core: Final = make_core(
+ model_list=[
+ deployment("g", "unset"),
+ deployment("g", "positive", **{metric: 5}),
+ deployment("g", "zero", **{metric: 0}),
+ ]
+ )
+ assert await _picked_ids(core, "g") == {"positive"}
+
+
+@pytest.mark.asyncio
+async def test_weight_takes_precedence_over_rpm_and_tpm(make_core: MakeCore) -> None:
+ core: Final = make_core(
+ model_list=[
+ deployment("g", "weighted", weight=1, rpm=0, tpm=0),
+ deployment("g", "by_limits", weight=0, rpm=100, tpm=100),
+ ]
+ )
+ assert await _picked_ids(core, "g") == {"weighted"}
+
+
+@pytest.mark.asyncio
+async def test_completion_calls_only_the_weighted_deployment(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a", weight=0), deployment("g", "b", weight=3)])
+ for _ in range(10):
+ await core.acompletion(model="g", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue]
+ assert set(host.deployments_called("acompletion")) == {"b"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("favoured", ["a", "b"])
+async def test_request_router_weights_override_deployment_weights(make_core: MakeCore, favoured: str) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a", weight=1), deployment("g", "b", weight=1)])
+ picked: Final = await _picked_ids(core, "g", request_kwargs={"_router_weights": {"g": {favoured: 1.0}}})
+ assert picked == {favoured}
+
+
+# --- order --------------------------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_select_picks_only_the_lowest_order(make_core: MakeCore) -> None:
+ core: Final = make_core(
+ model_list=[
+ deployment("g", "second", order=2),
+ deployment("g", "first_a", order=1),
+ deployment("g", "first_b", order=1),
+ deployment("g", "third", order=3),
+ ]
+ )
+ assert await _picked_ids(core, "g") <= {"first_a", "first_b"}
+
+
+@pytest.mark.asyncio
+async def test_completion_calls_only_the_lowest_order(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("g", "second", order=2), deployment("g", "first", order=1)])
+ for _ in range(5):
+ await core.acompletion(model="g", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue]
+ assert host.deployments_called("acompletion") == ["first"] * 5
+
+
+@pytest.mark.asyncio
+async def test_next_order_serves_while_the_lowest_order_cools_down(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "second", order=2), deployment("g", "first", order=1)],
+ allowed_fails=0,
+ num_retries=0,
+ cooldown_time=60,
+ )
+ host.answer("openai/first", rate_limit())
+ await _fail_through(core, "first")
+ assert await _picked_ids(core, "g") == {"second"}
+
+
+# --- routing strategies ----------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("routing_strategy", ["least-busy", "usage-based-routing"])
+@pytest.mark.parametrize("ids", [("a", "b"), ("b", "a")])
+async def test_strategy_without_traffic_picks_the_first_configured_deployment(
+ make_core: MakeCore, routing_strategy: str, ids: tuple[str, str]
+) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", deployment_id) for deployment_id in ids], routing_strategy=routing_strategy
+ )
+ assert await _picked_ids(core, "g", messages=MESSAGES) == {ids[0]}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("cheap_first", [True, False])
+async def test_cost_based_routing_picks_the_cheapest_deployment(make_core: MakeCore, cheap_first: bool) -> None:
+ cheap: Final = deployment("g", "cheap", input_cost_per_token=0.000001, output_cost_per_token=0.000002)
+ pricey: Final = deployment("g", "pricey", input_cost_per_token=0.00001, output_cost_per_token=0.00002)
+ core: Final = make_core(
+ model_list=[cheap, pricey] if cheap_first else [pricey, cheap], routing_strategy="cost-based-routing"
+ )
+ assert await _picked_ids(core, "g", messages=MESSAGES) == {"cheap"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("ids", [("tight", "roomy"), ("roomy", "tight")])
+async def test_usage_based_v2_skips_a_deployment_whose_tpm_the_request_exceeds(
+ make_core: MakeCore, ids: tuple[str, str]
+) -> None:
+ limits: Final = {"tight": 1, "roomy": 100_000}
+ core: Final = make_core(
+ model_list=[deployment("g", deployment_id, tpm=limits[deployment_id]) for deployment_id in ids],
+ routing_strategy="usage-based-routing-v2",
+ )
+ assert await _picked_ids(core, "g", messages=MESSAGES) == {"roomy"}
+
+
+@pytest.mark.asyncio
+async def test_usage_based_v2_rate_limits_when_the_request_exceeds_every_tpm(make_core: MakeCore) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a", tpm=1), deployment("g", "b", tpm=1)],
+ routing_strategy="usage-based-routing-v2",
+ )
+ with pytest.raises(
+ litellm.RateLimitError, match=re.escape("No deployments available for selected model. Passed model=g")
+ ):
+ await core.select("g", messages=MESSAGES) # pyright: ignore[reportCallIssue]
+
+
+# --- cooldowns ----------------------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("error", [rate_limit, auth_error, server_error, timeout], ids=lambda e: e.__name__)
+async def test_a_deployment_that_failed_past_allowed_fails_is_not_picked(
+ make_core: MakeCore, host: ScriptedHost, error: Callable[[], Exception]
+) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a"), deployment("g", "b")], allowed_fails=0, num_retries=0, cooldown_time=60
+ )
+ host.answer("openai/a", error())
+ await _fail_through(core, "a")
+ assert await _picked_ids(core, "g") == {"b"}
+
+
+@pytest.mark.asyncio
+async def test_completion_avoids_a_cooling_deployment(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a"), deployment("g", "b")], allowed_fails=0, num_retries=0, cooldown_time=60
+ )
+ host.answer("openai/a", rate_limit())
+ await _fail_through(core, "a")
+ for _ in range(5):
+ await core.acompletion(model="g", messages=MESSAGES) # pyright: ignore[reportAttributeAccessIssue]
+ assert host.deployments_called("acompletion") == ["a", "b", "b", "b", "b", "b"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("error", [bad_request, context_window], ids=lambda e: e.__name__)
+async def test_client_errors_do_not_cool_a_deployment_down(
+ make_core: MakeCore, host: ScriptedHost, error: Callable[[], Exception]
+) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a"), deployment("g", "b")], allowed_fails=0, num_retries=0, cooldown_time=60
+ )
+ host.answer("openai/a", error())
+ await _fail_through(core, "a", times=3)
+ assert await core.select("a") is not None
+ assert "a" in await _picked_ids(core, "g", times=60)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("allowed_fails", [1, 2, 3])
+async def test_a_deployment_stays_pickable_until_it_fails_more_than_allowed_fails(
+ make_core: MakeCore, host: ScriptedHost, allowed_fails: int
+) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a"), deployment("g", "b")],
+ allowed_fails=allowed_fails,
+ num_retries=0,
+ cooldown_time=60,
+ )
+ host.answer("openai/a", rate_limit())
+ await _fail_through(core, "a", times=allowed_fails)
+ assert "a" in await _picked_ids(core, "g", times=60)
+ await _fail_through(core, "a")
+ assert await _picked_ids(core, "g") == {"b"}
+
+
+@pytest.mark.asyncio
+async def test_a_single_deployment_group_is_never_cooled_down(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(model_list=[deployment("g", "a")], allowed_fails=0, num_retries=0, cooldown_time=60)
+ host.answer("openai/a", rate_limit())
+ await _fail_through(core, "a", times=3)
+ assert await _picked_ids(core, "g") == {"a"}
+
+
+@pytest.mark.asyncio
+async def test_selecting_a_cooling_deployment_by_id_still_returns_it(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a"), deployment("g", "b")], allowed_fails=0, num_retries=0, cooldown_time=60
+ )
+ host.answer("openai/a", rate_limit())
+ await _fail_through(core, "a")
+ assert await _picked_ids(core, "g") == {"b"}
+ assert await _picked_ids(core, "a") == {"a"}
+
+
+async def _all_cooling_core(make_core: MakeCore, host: ScriptedHost) -> RouterCore:
+ core: Final = make_core(
+ model_list=[deployment("g", "a"), deployment("g", "b")], allowed_fails=0, num_retries=0, cooldown_time=60
+ )
+ host.answer("openai/a", rate_limit())
+ host.answer("openai/b", rate_limit())
+ await _fail_through(core, "a")
+ await _fail_through(core, "b")
+ return core
+
+
+ALL_COOLING_MESSAGE: Final = (
+ "No deployments available for selected model, Try again in 60 seconds. All deployments for selected "
+ "model are in cooldown. Passed model=g. pre-call-checks=False, cooldown_list=['a', 'b']"
+)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("entry", ["select", "acompletion", "completion"])
+async def test_every_deployment_cooling_raises_router_rate_limit_error(
+ make_core: MakeCore, host: ScriptedHost, entry: str
+) -> None:
+ core: Final = await _all_cooling_core(make_core, host)
+ calls_before: Final = len(host.calls)
+ call: Final = core.select("g") if entry == "select" else core.call(entry, {"model": "g", "messages": MESSAGES})
+ with pytest.raises(RouterRateLimitError) as raised:
+ await call
+ assert str(raised.value) == ALL_COOLING_MESSAGE
+ assert raised.value.model == "g"
+ assert raised.value.cooldown_time == 60
+ assert sorted(raised.value.cooldown_list) == ["a", "b"]
+ assert len(host.calls) == calls_before
+
+
+@pytest.mark.asyncio
+async def test_cooling_down_one_group_leaves_other_groups_pickable(make_core: MakeCore, host: ScriptedHost) -> None:
+ core: Final = make_core(
+ model_list=[deployment("g", "a"), deployment("g", "b"), deployment("h", "c"), deployment("h", "d")],
+ allowed_fails=0,
+ num_retries=0,
+ cooldown_time=60,
+ )
+ host.answer("openai/a", rate_limit())
+ host.answer("openai/b", rate_limit())
+ await _fail_through(core, "a")
+ await _fail_through(core, "b")
+ with pytest.raises(RouterRateLimitError):
+ await core.select("g")
+ assert await _picked_ids(core, "h") <= {"c", "d"}
diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py
index 9bfa78ab288..9996ffcb098 100644
--- a/tests/unit/test_router/test_router.py
+++ b/tests/unit/test_router/test_router.py
@@ -2893,11 +2893,11 @@ def test_switch_routing_strategy_installs_lar1_then_restores_the_default_selecto
},
)
assert router.routing_strategy == "lar1"
- assert "_core_async_get_available_deployment" in router.__dict__
+ assert "_core_async_get_available_deployment" in router._core.__dict__
router._switch_routing_strategy("usage-based-routing-v2", {})
assert router.lowesttpm_logger_v2 is not None
- assert "async_get_available_deployment" not in router.__dict__
+ assert "_core_async_get_available_deployment" not in router._core.__dict__
@pytest.mark.parametrize(
@@ -22656,6 +22656,9 @@ _SSE_CHUNKS: Final[tuple[bytes, ...]] = tuple(
b'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"gpt-5.6",'
b'"choices":[{"index":0,"delta":{"content":"x"},"finish_reason":null}]}\n\n'
for _ in range(5)
+) + (
+ b'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"gpt-5.6",'
+ b'"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
)
@@ -24271,7 +24274,7 @@ def _record_phase_events(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, dic
def record(name: str, attributes: dict[str, str | int]) -> None:
events.append((name, dict(attributes)))
- monkeypatch.setattr(litellm.router, "phase_event", record)
+ monkeypatch.setattr(litellm.router_python_core, "phase_event", record)
return events
@@ -25666,7 +25669,9 @@ def test_get_model_list_from_model_alias_should_not_iterate_for_non_alias_lookup
],
model_group_alias={"alias-1": "gpt-5.5"},
)
- router.model_group_alias = NoItemsAliasDict({f"alias-{idx}": "gpt-5.5" for idx in range(200)})
+ # Assigning model_group_alias copies the value (a changeset detaches it), so the tripwire goes
+ # straight onto the core, where the lookup reads it.
+ router._core._core_model_group_alias = NoItemsAliasDict({f"alias-{idx}": "gpt-5.5" for idx in range(200)})
model_alias_list = router.get_model_list_from_model_alias(model_name="gpt-5-mini")
assert model_alias_list == []
@@ -25686,7 +25691,9 @@ def test_map_team_model_should_not_iterate_aliases_for_non_alias_team_model_name
],
model_group_alias={"alias-1": "gpt-5.5"},
)
- router.model_group_alias = NoItemsAliasDict({f"alias-{idx}": "gpt-5.5" for idx in range(200)})
+ # Assigning model_group_alias copies the value (a changeset detaches it), so the tripwire goes
+ # straight onto the core, where the lookup reads it.
+ router._core._core_model_group_alias = NoItemsAliasDict({f"alias-{idx}": "gpt-5.5" for idx in range(200)})
# map_team_model should return the public name unchanged (not the internal UUID name)
# so the router can find all sibling deployments via team_id filtering
@@ -29139,13 +29146,13 @@ def test_reset_custom_routing_strategy():
router = _create_custom_routing_router()
router.set_custom_routing_strategy(SpecialEndpointRoutingStrategy(router))
- assert "get_available_deployment" in router.__dict__
- assert "_core_async_get_available_deployment" in router.__dict__
+ assert "get_available_deployment" in router._core.__dict__
+ assert "_core_async_get_available_deployment" in router._core.__dict__
router._reset_custom_routing_strategy()
- assert "get_available_deployment" not in router.__dict__
- assert "async_get_available_deployment" not in router.__dict__
+ assert "get_available_deployment" not in router._core.__dict__
+ assert "_core_async_get_available_deployment" not in router._core.__dict__
assert router.async_get_available_deployment.__func__ is Router.async_get_available_deployment
router._reset_custom_routing_strategy()
@@ -30985,7 +30992,7 @@ async def test_responses_fallback_wrapper_disconnect_billing_keeps_the_primary_s
_SYNC_SECONDS: Final = 10
-_ROUTER_FILE: Final = Router.__init__.__code__.co_filename
+_ROUTER_FILE: Final = Router._update_deployment_indices_after_removal.__code__.co_filename
_INDEX_LOCK_LINES: Final = frozenset(
number
for number, line in enumerate(linecache.getlines(_ROUTER_FILE), start=1)
@@ -31442,6 +31449,7 @@ def test_request_carries_image_sees_chat_parts_and_responses_input_items():
assert litellm.Router._request_carries_image(None, {"input": _IMAGE_RESPONSES_INPUT}) is True
assert litellm.Router._request_carries_image([{"role": "user", "content": "Say hi"}], {}) is False
assert litellm.Router._request_carries_image(None, {"input": "Say hi"}) is False
+ assert litellm.Router._request_carries_image(None, {"input": ["safe text", "more safe text"]}) is False
@pytest.mark.asyncio
diff --git a/tests/unit/test_router/test_router_entry_points.py b/tests/unit/test_router/test_router_entry_points.py
index 366e16a8e91..81c1982b987 100644
--- a/tests/unit/test_router/test_router_entry_points.py
+++ b/tests/unit/test_router/test_router_entry_points.py
@@ -359,7 +359,7 @@ def test_settings_snapshot_is_frozen(router):
def test_unset_setting_raises_attribute_error_through_old_name():
r = _router()
try:
- del r.__dict__["_core_kubernetes_pod_discovery"]
+ del r._core.__dict__["_core_kubernetes_pod_discovery"]
with warnings.catch_warnings():
warnings.simplefilter("ignore")
assert getattr(r, "kubernetes_pod_discovery", "missing") == "missing"
@@ -372,14 +372,14 @@ def test_unset_setting_raises_attribute_error_through_old_name():
def test_unset_attribute_error_names_the_public_attribute(router):
# Reading (or deleting) an old attribute the router never set raises what reading it raised before.
- router.__dict__.pop("_core_slack_alerting_logger", None)
+ router._core.__dict__.pop("_core_slack_alerting_logger", None)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
with pytest.raises(AttributeError) as read:
_ = router.slack_alerting_logger
with pytest.raises(AttributeError) as deleted:
del router.slack_alerting_logger
- del router.__dict__["_core_kubernetes_pod_discovery"]
+ del router._core.__dict__["_core_kubernetes_pod_discovery"]
with pytest.raises(AttributeError) as setting:
_ = router.kubernetes_pod_discovery
for caught, name in ((read, "slack_alerting_logger"), (setting, "kubernetes_pod_discovery")):
diff --git a/tests/unit/test_router/test_router_facade.py b/tests/unit/test_router/test_router_facade.py
new file mode 100644
index 00000000000..0539ebe43b9
--- /dev/null
+++ b/tests/unit/test_router/test_router_facade.py
@@ -0,0 +1,52 @@
+"""
+The ``litellm.Router`` facade's own helpers in litellm/router.py: how it binds the constructor
+arguments, which members it leaves to the facade, and the module attributes it forwards to the core.
+"""
+
+import pytest
+
+import litellm.router as router_module
+from litellm import router_python_core
+from litellm.router import Router, _class_descriptor, router_arguments
+
+
+def test_router_arguments_binds_positional_arguments_to_their_parameter_names():
+ model_list = [{"model_name": "g", "litellm_params": {"model": "openai/gpt-4o-mini"}}]
+
+ arguments = router_arguments(model_list, num_retries=2)
+
+ assert dict(arguments) == {"model_list": model_list, "num_retries": 2}
+
+
+def test_router_arguments_leaves_unset_parameters_out():
+ assert dict(router_arguments()) == {}
+
+
+def test_router_arguments_rejects_an_unknown_argument_naming_the_router():
+ with pytest.raises(TypeError, match=r"^Router\.__init__\(\) got an unexpected keyword argument 'not_a_setting'"):
+ router_arguments(not_a_setting=1)
+
+
+def test_router_arguments_are_read_only():
+ arguments = router_arguments(num_retries=2)
+
+ with pytest.raises(TypeError):
+ arguments["num_retries"] = 3 # pyright: ignore[reportIndexIssue] # proving the mapping is read-only
+
+
+def test_class_descriptor_is_true_for_a_data_descriptor_on_the_class():
+ assert _class_descriptor(Router, "_core")
+
+
+def test_class_descriptor_is_false_for_a_plain_method_or_a_missing_name():
+ assert not _class_descriptor(Router, "from_config")
+ assert not _class_descriptor(Router, "no_such_member")
+
+
+def test_module_getattr_forwards_old_helpers_to_the_core_module():
+ assert router_module.__getattr__("_stream_pre_content_error") is router_python_core._stream_pre_content_error
+
+
+def test_module_getattr_raises_attribute_error_for_an_unknown_name():
+ with pytest.raises(AttributeError):
+ router_module.__getattr__("no_such_router_helper")
diff --git a/tests/unit/test_router/test_router_helper_utils.py b/tests/unit/test_router/test_router_helper_utils.py
index 8929c5fd345..4964dea498f 100644
--- a/tests/unit/test_router/test_router_helper_utils.py
+++ b/tests/unit/test_router/test_router_helper_utils.py
@@ -1182,7 +1182,7 @@ def test_deployment_callback_respects_cooldown_time(model_list):
},
}
- with patch("litellm.router.set_cooldown_deployments") as mock_set:
+ with patch("litellm.router_python_core.set_cooldown_deployments") as mock_set:
router.deployment_callback_on_failure(
kwargs=kwargs,
completion_response=None,
@@ -1554,7 +1554,7 @@ def _rpm_tpm_router(model_id: str) -> Router:
def _pin_router_clock(monkeypatch: pytest.MonkeyPatch, pinned: datetime) -> None:
- monkeypatch.setattr("litellm.router.get_utc_datetime", lambda: pinned)
+ monkeypatch.setattr("litellm.router_python_core.get_utc_datetime", lambda: pinned)
monkeypatch.setattr(
"litellm.router_utils.pre_call_checks.io_token_rate_limit_check.get_utc_datetime", lambda: pinned
)
@@ -2398,10 +2398,10 @@ def _routers_seen_in_success_loop_discarding_first(first: Router, second: Router
for callback in litellm.success_callback:
if not isinstance(callback, MethodType):
continue
- if callback.__self__ is first:
+ if callback.__self__ is first._core:
first.discard()
yield first
- if callback.__self__ is second:
+ if callback.__self__ is second._core:
yield second
diff --git a/tests/unit/test_router/test_router_provider_endpoints.py b/tests/unit/test_router/test_router_provider_endpoints.py
index f8b57410872..d2331c00804 100644
--- a/tests/unit/test_router/test_router_provider_endpoints.py
+++ b/tests/unit/test_router/test_router_provider_endpoints.py
@@ -53,7 +53,7 @@ def httpx_transport(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
@pytest.fixture
def router_minute_pinned(monkeypatch: pytest.MonkeyPatch) -> None:
pinned: Final = datetime(2026, 1, 1, 12, 0, 30, tzinfo=timezone.utc)
- monkeypatch.setattr("litellm.router.get_utc_datetime", lambda: pinned)
+ monkeypatch.setattr("litellm.router_python_core.get_utc_datetime", lambda: pinned)
class _RouterLoggingCapture(CustomLogger):
diff --git a/tests/unit/test_router_silent_experiment.py b/tests/unit/test_router_silent_experiment.py
index b696a61aa3c..fc787fcf74a 100644
--- a/tests/unit/test_router_silent_experiment.py
+++ b/tests/unit/test_router_silent_experiment.py
@@ -620,7 +620,7 @@ def test_sync_shadow_gets_kwargs_snapshot_taken_before_primary_mutates_them(reco
router = Router(model_list=_tagged_primary_model_list())
with patch( # test-quality-ok: Router has no thread factory to inject; deferring start is the only deterministic way to expose the race
- "litellm.router.threading", SimpleNamespace(Thread=_DeferredThread)
+ "litellm.router_python_core.threading", SimpleNamespace(Thread=_DeferredThread)
):
response = router.completion(
model="primary-model",
@@ -652,7 +652,7 @@ def test_sync_shadow_workers_do_not_share_metadata_with_each_other(recording_log
router = Router(model_list=_streaming_model_list(["shadow-a", "shadow-b"]))
with patch( # test-quality-ok: Router has no thread factory to inject; deferring start is the only deterministic way to expose the race
- "litellm.router.threading", SimpleNamespace(Thread=_DeferredThread)
+ "litellm.router_python_core.threading", SimpleNamespace(Thread=_DeferredThread)
):
router.completion(
model="primary-model",
diff --git a/tests/unit/test_router_weighted_failover.py b/tests/unit/test_router_weighted_failover.py
index 8b7fc81dbde..c00670473af 100644
--- a/tests/unit/test_router_weighted_failover.py
+++ b/tests/unit/test_router_weighted_failover.py
@@ -338,7 +338,7 @@ async def test_maybe_run_weighted_failover_persists_excluded_ids_to_kwargs(monke
async def _stub_run_async_fallback(*args, **kwargs):
return "ok"
- monkeypatch.setattr("litellm.router.run_async_fallback", _stub_run_async_fallback)
+ monkeypatch.setattr("litellm.router_python_core.run_async_fallback", _stub_run_async_fallback)
exc = Exception("fail")
exc.failed_deployment_id = "A"
@@ -807,11 +807,11 @@ async def test_maybe_run_weighted_failover_skips_when_remaining_all_in_cooldown(
run_async_fallback_called = True
return "should not reach here"
- monkeypatch.setattr("litellm.router.run_async_fallback", _should_not_be_called)
+ monkeypatch.setattr("litellm.router_python_core.run_async_fallback", _should_not_be_called)
# Patch cooldown so B and C appear in cooldown.
with patch(
- "litellm.router.async_get_cooldown_deployments",
+ "litellm.router_python_core.async_get_cooldown_deployments",
new=AsyncMock(return_value=["B", "C"]),
):
result = await router._maybe_run_weighted_failover(
@@ -867,10 +867,10 @@ async def test_maybe_run_weighted_failover_proceeds_when_one_healthy_remains(
run_async_fallback_called = True
return "ok from C"
- monkeypatch.setattr("litellm.router.run_async_fallback", _stub_run_async_fallback)
+ monkeypatch.setattr("litellm.router_python_core.run_async_fallback", _stub_run_async_fallback)
with patch(
- "litellm.router.async_get_cooldown_deployments",
+ "litellm.router_python_core.async_get_cooldown_deployments",
new=AsyncMock(return_value=["B"]),
):
result = await router._maybe_run_weighted_failover(
@@ -936,7 +936,7 @@ async def test_failover_falls_through_to_external_fallback_when_remaining_in_coo
# Put B in cooldown so weighted failover can't use it after A fails.
with patch(
- "litellm.router.async_get_cooldown_deployments",
+ "litellm.router_python_core.async_get_cooldown_deployments",
new=AsyncMock(return_value=["B"]),
):
response = await router.acompletion(
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
index e0968e9b2cf..fbc3c0e47c3 100644
--- a/tests/unit/test_scheduler.py
+++ b/tests/unit/test_scheduler.py
@@ -5,12 +5,20 @@ import asyncio
import importlib
import json
import os
+import time
from collections.abc import Sequence
from typing import Final
import pytest
+import redis
+import redis.asyncio
+from fakeredis import FakeRedis, FakeServer
+from fakeredis.aioredis import FakeConnection
+from redis.asyncio import BlockingConnectionPool
import litellm
+from litellm.caching.in_memory_cache import InMemoryCache
+from litellm.caching.redis_cache import RedisCache
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.scheduler import FlowItem, Scheduler, SchedulerCacheKeys
from litellm.utils import _invalidate_model_cost_lowercase_map
@@ -207,6 +215,53 @@ class _ExpiringCache:
self.store[key] = value
+class _SchedulerClock:
+ now: float = 1000.0
+
+ def __call__(self) -> float:
+ return self.now
+
+
+@pytest.mark.asyncio
+async def test_waiter_observes_shared_expiry_after_reading_just_before_it(monkeypatch: pytest.MonkeyPatch) -> None:
+ clock: Final = _SchedulerClock()
+ server: Final = FakeServer()
+
+ class Pool(BlockingConnectionPool):
+ def __init__(self, **kwargs: object) -> None:
+ super().__init__(connection_class=FakeConnection, server=server, **kwargs)
+
+ monkeypatch.setattr(time, "time", clock)
+ monkeypatch.setattr(redis, "Redis", FakeRedis)
+ monkeypatch.setattr(redis.asyncio, "BlockingConnectionPool", Pool)
+ cache: Final = RedisCache(host="scheduler-expiry")
+ scheduler: Final = Scheduler(redis_cache=cache)
+ scheduler.cache.in_memory_cache = InMemoryCache(clock=clock)
+ waiter: Final = FlowItem(priority=2, request_id="waiter", model_name="expiry")
+ key: Final = f"{SchedulerCacheKeys.queue.value}:{waiter.model_name}"
+ try:
+ await cache.async_set_cache(key, [(1, "dead-replica")])
+ assert await scheduler.get_queue(waiter.model_name) == [(1, "dead-replica")]
+ clock.now = 1000.1
+ await scheduler.add_request(waiter)
+ clock.now = 1005.05
+ assert not await scheduler.poll(waiter, [])
+ clock.now = 1005.2
+ server.connected = False
+ assert not await scheduler.poll(waiter, [])
+ server.connected = True
+ assert await cache.async_get_cache(key) is None
+ assert await scheduler.get_queue(waiter.model_name) == []
+ server.connected = False
+ assert await scheduler.get_queue(waiter.model_name) == []
+ server.connected = True
+ assert await scheduler.poll(waiter, [])
+ assert await scheduler.get_queue(waiter.model_name) == []
+ finally:
+ server.connected = True
+ await cache.disconnect()
+
+
@pytest.mark.asyncio
async def test_wait_for_turn_during_cooldown_survives_the_queue_key_expiring():
cache: Final = _ExpiringCache()
diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json
index c14f3e6f036..d4ac60e501b 100644
--- a/ui/litellm-dashboard/eslint-suppressions.json
+++ b/ui/litellm-dashboard/eslint-suppressions.json
@@ -1142,11 +1142,6 @@
"count": 1
}
},
- "src/app/chat/page.tsx": {
- "no-nested-ternary": {
- "count": 1
- }
- },
"src/app/login/LoginPage.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
@@ -1737,10 +1732,10 @@
"count": 5
},
"no-restricted-syntax": {
- "count": 139
+ "count": 133
},
"prefer-const": {
- "count": 28
+ "count": 26
}
},
"src/components/object_permissions_view.tsx": {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx
index a3d75df9606..fa4b48a5a9d 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx
@@ -9,7 +9,7 @@ const { getSettings } = vi.hoisted(() => ({
getSettings: vi.fn().mockResolvedValue({ values: { enable_projects_ui: true } }),
}));
-vi.mock("@/components/networking", () => ({ getUiSettings: getSettings, getUISettings: getSettings }));
+vi.mock("@/components/networking", () => ({ getUiSettings: getSettings }));
vi.mock("@/components/leftnav", () => ({
default: ({ enableProjectsUI }: ComponentProps) => (
diff --git a/ui/litellm-dashboard/src/app/chat/page.background.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/page.background.integration.test.tsx
new file mode 100644
index 00000000000..53be87ce30c
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/chat/page.background.integration.test.tsx
@@ -0,0 +1,229 @@
+import { act, fireEvent, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
+import { ChatShellProvider } from "@/contexts/ChatShellContext";
+import type { Conversation } from "@/components/chat/types";
+import ChatConversationPage from "./page";
+
+vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }));
+
+const storageKey = "litellm_chat_history_v1:background-user";
+const requests: Record[] = [];
+let supported: boolean;
+let status: "in_progress" | "completed" | "cancelled";
+let retrievalStatus: number;
+let cancellations: number;
+let acceptance: Promise | undefined;
+const saved = (): Conversation[] => JSON.parse(localStorage.getItem(storageKey) ?? "[]");
+const result = () => ({
+ id: "litellm_poll_research",
+ status,
+ usage: status === "completed" ? { input_tokens: 4, output_tokens: 9, total_tokens: 13, cost: "0.125" } : undefined,
+ litellm_response_timing:
+ status === "completed" ? { time_to_first_token_ms: 250, total_latency_ms: 12500 } : undefined,
+ output:
+ status === "completed"
+ ? [
+ {
+ type: "mcp_call",
+ id: "call_inventory",
+ name: "lookup_inventory",
+ arguments: "{}",
+ output: "12 widgets",
+ server_label: "inventory",
+ },
+ { type: "message", content: [{ type: "output_text", text: "There are 12 widgets in stock." }] },
+ ].filter((item) => item.type !== "mcp_call" || Boolean(requests.at(-1)?.tools))
+ : [],
+});
+
+function renderChat(id?: string) {
+ return renderWithProviders(
+
+
+ ,
+ { searchParams: id ? { id } : {} },
+ );
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ testQueryClient.clear();
+ requests.length = 0;
+ cancellations = 0;
+ acceptance = undefined;
+ supported = true;
+ retrievalStatus = 200;
+ status = "in_progress";
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const request =
+ input instanceof Request ? input : new Request(new URL(String(input), window.location.origin), init);
+ const path = new URL(request.url).pathname;
+ if (path.endsWith("/model_group/info"))
+ return Response.json({
+ data: [{ model_group: "research-chat", mode: "chat", supports_background_chat: supported }],
+ });
+ if (path.endsWith("/v1/mcp/server")) return Response.json([{ server_id: "inventory", server_name: "inventory" }]);
+ if (path.endsWith("/mcp-rest/tools/list")) return Response.json({ tools: [{ name: "lookup_inventory" }] });
+ if (path.endsWith("/responses")) {
+ const body = JSON.parse(await request.text());
+ requests.push(body);
+ if (body.background) {
+ await acceptance;
+ request.signal.throwIfAborted();
+ return Response.json({ ...result(), status: "queued" });
+ }
+ return new Response(
+ 'data: {"type":"response.output_text.delta","delta":"Foreground answer"}\n\ndata: {"type":"response.completed","response":{"id":"resp_foreground","output":[]}}\n\n',
+ { headers: { "Content-Type": "text/event-stream" } },
+ );
+ }
+ if (path.endsWith("/litellm_poll_research/cancel")) {
+ cancellations++;
+ status = "cancelled";
+ return Response.json(result());
+ }
+ if (path.endsWith("/litellm_poll_research"))
+ return Response.json(retrievalStatus === 200 ? result() : { error: { message: "Unavailable" } }, {
+ status: retrievalStatus,
+ });
+ return Response.json({}, { status: 404 });
+ }),
+ );
+});
+afterEach(() => vi.unstubAllGlobals());
+
+async function startResearch(withTools = true) {
+ const user = userEvent.setup();
+ await screen.findByRole("button", { name: "research-chat" });
+ if (withTools) {
+ await user.click(screen.getByRole("button", { name: "Connect tools" }));
+ await user.click(await screen.findByRole("switch"));
+ await user.keyboard("{Escape}");
+ }
+ fireEvent.change(screen.getByRole("textbox", { name: "Message", exact: true }), {
+ target: { value: "Check inventory" },
+ });
+ await user.click(screen.getByRole("button", { name: "Send", exact: true }));
+}
+
+it("should retrieve the completed tool run after closing and reopening without submitting it again", async () => {
+ const first = renderChat();
+ await startResearch();
+ await screen.findByText("Running in the background. You can close this tab and return to this chat.");
+ await waitFor(() => expect(saved()[0]?.messages.at(-1)?.backgroundRun?.id).toBe("litellm_poll_research"));
+ const id = saved()[0].id;
+ first.unmount();
+ expect(cancellations).toBe(0);
+ status = "completed";
+ renderChat(id);
+ expect(await screen.findByText("There are 12 widgets in stock.")).toBeVisible();
+ expect(screen.getByRole("button", { name: "lookup_inventory" })).toBeVisible();
+ expect(screen.getByLabelText(/^Cost:/)).toHaveTextContent("$0.125");
+ expect(screen.getByLabelText(/^TTFT:/)).toHaveTextContent("0.25s");
+ expect(screen.getByLabelText(/^Total Latency:/)).toHaveTextContent("12.50s");
+ await waitFor(() =>
+ expect(saved()[0]?.messages.at(-1)).toMatchObject({ timeToFirstToken: 250, totalLatency: 12500 }),
+ );
+ expect(requests).toHaveLength(1);
+ expect(requests[0]).toMatchObject({
+ background: true,
+ stream: false,
+ tools: [{ type: "mcp", server_label: "inventory" }],
+ });
+ expect(screen.queryByRole("button", { name: "Stop request" })).not.toBeInTheDocument();
+ fireEvent.change(screen.getByRole("textbox", { name: "Message", exact: true }), {
+ target: { value: "What was the result?" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Send", exact: true }));
+ await waitFor(() => expect(requests).toHaveLength(2));
+ expect(requests[1]).toMatchObject({ background: true, stream: false });
+ expect(requests[1]).not.toHaveProperty("previous_response_id");
+ expect(requests[1].input).toEqual(
+ expect.arrayContaining([{ type: "function_call_output", call_id: "call_inventory", output: "12 widgets" }]),
+ );
+});
+
+it("should continue an ordinary chat without MCP tools after closing and reopening", async () => {
+ const first = renderChat();
+ await startResearch(false);
+ await screen.findByText("Running in the background. You can close this tab and return to this chat.");
+ await waitFor(() => expect(saved()[0]?.messages.at(-1)?.backgroundRun?.id).toBe("litellm_poll_research"));
+ expect(requests[0]).toMatchObject({ background: true, stream: false });
+ expect(requests[0]).not.toHaveProperty("tools");
+ const id = saved()[0].id;
+ first.unmount();
+ status = "completed";
+ renderChat(id);
+ expect(await screen.findByText("There are 12 widgets in stock.")).toBeVisible();
+ expect(screen.getByLabelText(/^TTFT:/)).toHaveTextContent("0.25s");
+ expect(screen.getByLabelText(/^Total Latency:/)).toHaveTextContent("12.50s");
+ await waitFor(() =>
+ expect(saved()[0]?.messages.at(-1)).toMatchObject({ timeToFirstToken: 250, totalLatency: 12500 }),
+ );
+ expect(requests).toHaveLength(1);
+ expect(cancellations).toBe(0);
+});
+
+it("should cancel a background run only when Stop is clicked", async () => {
+ renderChat();
+ await startResearch();
+ await screen.findByText("Running in the background. You can close this tab and return to this chat.");
+ fireEvent.click(screen.getByRole("button", { name: "Stop request" }));
+ expect(await screen.findByText("Cancellation requested")).toBeVisible();
+ expect(cancellations).toBe(1);
+});
+
+it("should retain ordinary streaming when background support is unavailable", async () => {
+ supported = false;
+ renderChat();
+ await startResearch();
+ expect(await screen.findByText("Foreground answer")).toBeVisible();
+ expect(requests[0]).toMatchObject({ stream: true });
+ expect(requests[0]).not.toHaveProperty("background");
+ expect(screen.getByText("Keep this tab open. Background runs are not available for this chat.")).toBeVisible();
+});
+
+it("should let the user reconnect after an authentication failure without resubmitting work", async () => {
+ const first = renderChat();
+ await startResearch();
+ await waitFor(() => expect(saved()[0]?.messages.at(-1)?.backgroundRun?.id).toBe("litellm_poll_research"));
+ const id = saved()[0].id;
+ first.unmount();
+ retrievalStatus = 401;
+ renderChat(id);
+ await screen.findByRole("button", { name: "Reconnect" });
+ retrievalStatus = 200;
+ status = "completed";
+ fireEvent.click(screen.getByRole("button", { name: "Reconnect" }));
+ expect(await screen.findByText("There are 12 widgets in stock.")).toBeVisible();
+ expect(requests).toHaveLength(1);
+});
+
+it.each([false, true])(
+ "should honor Stop before a background submission returns its ID (tools=%s)",
+ async (withTools) => {
+ let accept!: () => void;
+ acceptance = new Promise((resolve) => {
+ accept = resolve;
+ });
+ renderChat();
+ await startResearch(withTools);
+ await waitFor(() => expect(requests).toHaveLength(1));
+ fireEvent.click(screen.getByRole("button", { name: "Stop request" }));
+ await act(async () => accept());
+ expect(await screen.findByText("Cancellation requested")).toBeVisible();
+ expect(cancellations).toBe(1);
+ expect(requests).toHaveLength(1);
+ await waitFor(() => expect(saved()[0]?.messages.at(-1)?.backgroundRun?.status).toBe("cancelled"));
+ },
+);
diff --git a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx
index 6b333750b78..5ea5db56920 100644
--- a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx
@@ -64,7 +64,7 @@ vi.mock("@/contexts/ChatShellContext", () => ({
},
}));
-const ONE_TURN_ARG_COUNT = 25;
+const ONE_TURN_ARG_COUNT = 28;
const ON_TIMING_DATA_INDEX = 7;
const ON_USAGE_DATA_INDEX = 8;
const ON_TOTAL_LATENCY_INDEX = 24;
diff --git a/ui/litellm-dashboard/src/app/chat/page.tsx b/ui/litellm-dashboard/src/app/chat/page.tsx
index 85fc7133215..757bf010fd9 100644
--- a/ui/litellm-dashboard/src/app/chat/page.tsx
+++ b/ui/litellm-dashboard/src/app/chat/page.tsx
@@ -15,6 +15,7 @@ import type { AssistantMessageUpdate, ChatAttachment, ChatMessage } from "@/comp
import ChatModelPicker, { isChatModel, modelChatMode, type ChatMode } from "@/components/chat/ChatModelPicker";
import MoyaiChatComposer from "@/components/chat/moyai/MoyaiChatComposer";
import MoyaiChatHome from "@/components/chat/moyai/MoyaiChatHome";
+import { useBackgroundChat } from "@/components/chat/useBackgroundChat";
import { runChatRequest } from "@/components/chat/runChatRequest";
import { readChatAttachment, MAX_CHAT_ATTACHMENTS, CHAT_ATTACHMENT_ACCEPT } from "@/components/chat/attachmentHelpers";
import ChatFileChip from "@/components/chat/ChatFileChip";
@@ -102,7 +103,30 @@ export default function ChatConversationPage() {
const responsesSessionGenerationRef = useRef(0);
const previousResponsesConversationRef = useRef(activeConversationId);
const [prevConversationIdForSessionReset, setPrevConversationIdForSessionReset] = useState(activeConversationId);
- const [isStreaming, setIsStreaming] = useState(false);
+ const [isRequestInFlight, setIsStreaming] = useState(false);
+ const [cancelOnAcceptance, setCancelOnAcceptance] = useState(null);
+ const backgroundRequestRef = useRef(false);
+ const lastMessage = activeConversation?.messages.at(-1);
+ const background = useBackgroundChat(
+ activeConversationId,
+ lastMessage?.backgroundRun,
+ accessToken,
+ updateLastAssistantMessage,
+ );
+ const { pending: backgroundPending, cancelling: backgroundCancelling, cancel: cancelBackground } = background;
+ useEffect(() => {
+ if (!backgroundPending || backgroundCancelling || !activeConversationId) return;
+ if (cancelOnAcceptance !== activeConversationId) return;
+ void cancelBackground().then(() => {
+ setCancelOnAcceptance((current) => (current === activeConversationId ? null : current));
+ });
+ }, [activeConversationId, backgroundPending, backgroundCancelling, cancelBackground, cancelOnAcceptance]);
+
+ const isStreaming = isRequestInFlight || background.pending;
+ const supportsBackground = models.some(
+ (model) => model.model_group === selectedModel && model.supports_background_chat,
+ );
+ const useBackground = mode === "chat" && supportsBackground && !storageUnavailable;
const [inputText, setInputText] = useState("");
const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false);
const [storageBannerDismissed, setStorageBannerDismissed] = useState(false);
@@ -264,6 +288,7 @@ export default function ChatConversationPage() {
setAttachmentError(null);
setIsStreaming(true);
+ setCancelOnAcceptance(null);
abortControllerRef.current = new AbortController();
requestConversationRef.current = convId;
@@ -297,12 +322,14 @@ export default function ChatConversationPage() {
signal: requestController.signal,
messages: history,
previousResponseId,
+ background: requestMode === "chat" && useBackground,
mcpServers: selectedMCPServers,
onResponseId: (id: string | null) => {
if (responsesSessionGenerationRef.current === sessionGeneration) setResponsesSessionId(id);
},
onUpdate: (updates: AssistantMessageUpdate) => updateLastAssistantMessage(convId, updates),
};
+ backgroundRequestRef.current = request.background;
try {
await runChatRequest(request);
} finally {
@@ -326,13 +353,22 @@ export default function ChatConversationPage() {
updateLastAssistantMessage,
isStreaming,
responsesSessionId,
+ useBackground,
setConversationIdInUrl,
],
);
- const handleStop = useCallback(() => {
+ const handleStop = () => {
+ if (background.pending) {
+ void background.cancel();
+ return;
+ }
+ if (backgroundRequestRef.current) {
+ setCancelOnAcceptance(activeConversationId);
+ return;
+ }
abortControllerRef.current?.abort();
- }, []);
+ };
const handleEditAndResend = useCallback(
(messageId: string, newContent: string) => {
@@ -403,7 +439,21 @@ export default function ChatConversationPage() {
const showBlankState = !activeConversation || activeConversation.messages.length === 0;
+ const backgroundHint = () => {
+ if (background.cancelling || (isRequestInFlight && cancelOnAcceptance === activeConversationId))
+ return "Requesting cancellation…";
+ if (background.pending && storageUnavailable)
+ return "Keep this tab open. This run could not be saved in this browser.";
+ if (background.pending) return "Running in the background. You can close this tab and return to this chat.";
+ if (useBackground) return null;
+ return "Keep this tab open. Background runs are not available for this chat.";
+ };
+
const inputBar = (inConversation: boolean) => {
+ const hint = mode === "chat" || background.pending ? backgroundHint() : null;
+ const chatPlaceholder = inConversation
+ ? "Send a message..."
+ : "Describe a change, paste an error, or ask a question…";
const preparing = isLoadingModels || isReadingAttachments;
const submitDisabled = !hasMessageContent(inputText, attachments, mode) || !selectedModel || preparing;
return (
@@ -424,13 +474,7 @@ export default function ChatConversationPage() {
onChange={setInputText}
onSubmit={() => void handleSend(inputText)}
onCancel={handleStop}
- placeholder={
- mode === "image"
- ? "Describe the image you want to create…"
- : inConversation
- ? "Send a message..."
- : "Describe a change, paste an error, or ask a question…"
- }
+ placeholder={mode === "image" ? "Describe the image you want to create…" : chatPlaceholder}
isLoading={isStreaming}
submitLabel={mode === "image" ? "Generate image" : "Send"}
submitDisabled={submitDisabled}
@@ -536,6 +580,29 @@ export default function ChatConversationPage() {
: "No chat models are available for your account."}
)}
+ {background.connectionError && (
+
+ {background.connectionError}
+
+
+ )}
+ {lastMessage?.backgroundError && (
+
+ {lastMessage.backgroundError}
+
+ )}
+ {lastMessage?.backgroundRun?.status === "cancelled" && (
+
+ Cancellation requested
+
+ )}
+ {hint && (
+
+ {hint}
+
+ )}
{mode === "image" ? "Describe an image · Enter to generate" : "Drop files or paste images · Enter to send"}
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/BackgroundChatSettings.integration.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/BackgroundChatSettings.integration.test.tsx
new file mode 100644
index 00000000000..311094909ce
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/BackgroundChatSettings.integration.test.tsx
@@ -0,0 +1,98 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
+import BackgroundChatSettings from "./BackgroundChatSettings";
+
+let settings: {
+ enabled: boolean;
+ redis_configured: boolean;
+ managed_by_config: boolean;
+ can_save: boolean;
+};
+let failLoad: boolean;
+let failSave: boolean;
+const updates: unknown[] = [];
+
+beforeEach(() => {
+ testQueryClient.clear();
+ settings = {
+ enabled: false,
+ redis_configured: true,
+ managed_by_config: false,
+ can_save: true,
+ };
+ failLoad = false;
+ failSave = false;
+ updates.length = 0;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const request =
+ input instanceof Request ? input : new Request(new URL(String(input), window.location.origin), init);
+ if (request.method === "PATCH") {
+ const body = await request.json();
+ updates.push(body);
+ if (failSave) return Response.json({ detail: "Save failed" }, { status: 500 });
+ settings = { ...settings, enabled: body.enabled };
+ } else if (failLoad) return Response.json({ detail: "Unavailable" }, { status: 503 });
+ return Response.json(settings);
+ }),
+ );
+});
+afterEach(() => vi.unstubAllGlobals());
+
+it("saves background execution next to its explanation and retains it after reopening", async () => {
+ const first = renderWithProviders();
+ fireEvent.click(await screen.findByRole("switch", { name: "Continue chats in the background" }));
+ await waitFor(() => expect(screen.getByRole("switch")).toBeChecked());
+ expect(updates).toEqual([{ enabled: true }]);
+ expect(screen.getByRole("switch")).toBeChecked();
+ first.unmount();
+ testQueryClient.clear();
+ renderWithProviders();
+ expect(await screen.findByRole("switch")).toBeChecked();
+ fireEvent.click(screen.getByRole("switch"));
+ await waitFor(() => expect(screen.getByRole("switch")).not.toBeChecked());
+ expect(updates).toEqual([{ enabled: true }, { enabled: false }]);
+});
+
+it("links to Redis setup and prevents enabling without Redis", async () => {
+ settings.redis_configured = false;
+ renderWithProviders();
+ expect(await screen.findByRole("switch")).toHaveAttribute("aria-disabled", "true");
+ expect(screen.getByRole("link", { name: /Configure it in Caching/ })).toHaveAttribute(
+ "href",
+ expect.stringMatching(/\/caching$/),
+ );
+ expect(updates).toHaveLength(0);
+});
+
+it.each(["managed_by_config", "can_save"] as const)(
+ "explains a setting that cannot be edited because of %s",
+ async (field) => {
+ settings[field] = field === "managed_by_config";
+ renderWithProviders();
+ expect(await screen.findByRole("switch")).toHaveAttribute("aria-disabled", "true");
+ const message =
+ field === "managed_by_config" ? /Managed by your config file/ : /Enable database-backed configuration/;
+ expect(screen.getByText(message)).toBeVisible();
+ },
+);
+
+it("keeps a failed save off", async () => {
+ failSave = true;
+ renderWithProviders();
+ fireEvent.click(await screen.findByRole("switch"));
+ await waitFor(() => expect(updates).toHaveLength(1));
+ expect(screen.getByRole("switch")).not.toBeChecked();
+});
+
+it("offers retry for a settings fetch failure instead of claiming Redis is missing", async () => {
+ failLoad = true;
+ renderWithProviders();
+ await screen.findByRole("button", { name: "Retry" });
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ failLoad = false;
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(await screen.findByRole("switch")).not.toHaveAttribute("aria-disabled", "true");
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/BackgroundChatSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/BackgroundChatSettings.tsx
new file mode 100644
index 00000000000..428edfff86d
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/BackgroundChatSettings.tsx
@@ -0,0 +1,73 @@
+import { $api } from "@/lib/http/api";
+import { toast } from "@/lib/toast";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Switch } from "@/components/ui/switch";
+import { uiHref } from "@/utils/uiHref";
+
+export default function BackgroundChatSettings() {
+ const query = $api.useQuery("get", "/get/background_chat_settings");
+ const update = $api.useMutation("patch", "/update/background_chat_settings", {
+ onSuccess: () => {
+ void query.refetch();
+ toast.success("Background chat settings saved.");
+ },
+ onError: (error) => toast.fromError(error),
+ });
+
+ if (query.isPending) return ;
+ if (query.isError || !query.data)
+ return (
+
+ Could not load background chat settings.
+
+
+ );
+
+ const settings = query.data;
+ const readOnly = settings.managed_by_config || !settings.can_save;
+ const needsRedis = !settings.redis_configured && !settings.enabled;
+ const disabled = update.isPending || readOnly || needsRedis;
+
+ return (
+
+
update.mutate({ body: { enabled } })}
+ aria-label="Continue chats in the background"
+ aria-describedby="background-chat-description"
+ />
+
+
Continue chats in the background
+
+ Keep replies and tool runs going after users close the browser. Return to the same browser to see the result.
+
+ {!settings.redis_configured && (
+
+ Requires Redis.{" "}
+
+ Configure it in Caching → Coordination Redis
+
+ , then restart the proxy.
+
+ )}
+ {settings.managed_by_config && (
+
+ Managed by your config file. Update its background polling setting to change this.
+
+ )}
+ {!settings.can_save && !settings.managed_by_config && (
+
Enable database-backed configuration to save this setting.
+ )}
+ {!readOnly && (
+
+ Changes apply automatically when the proxy refreshes its configuration.
+
+ )}
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx
index 2aa5c738880..c2bfd6076d4 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx
@@ -5,6 +5,8 @@ import userEvent from "@testing-library/user-event";
import UISettings from "./UISettings";
import { toast } from "@/lib/toast";
+vi.mock("./BackgroundChatSettings", () => ({ default: () => null }));
+
const mockUseAuthorized = vi.hoisted(() => vi.fn());
const mockUseUISettings = vi.hoisted(() => vi.fn());
const mockUseUpdateUISettings = vi.hoisted(() => vi.fn());
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
index 3103851f280..08f345af928 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
@@ -16,6 +16,7 @@ import { relativeTimeOptions } from "@/components/shared/date_range_presets";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import PageVisibilitySettings from "./PageVisibilitySettings";
+import BackgroundChatSettings from "./BackgroundChatSettings";
interface SettingRowProps {
ariaLabel: string;
@@ -429,13 +430,15 @@ export default function UISettings() {
disabled={isUpdating}
onCheckedChange={handleToggleEnableChatUI}
ariaLabel={enableChatUIProperty?.description ?? "Enable Chat page"}
- label="[BETA] Enable Chat page (page will refresh)"
+ label="Enable Chat page (page will refresh)"
description={
enableChatUIProperty?.description ??
"If enabled, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth."
}
/>
+
+
diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.tsx b/ui/litellm-dashboard/src/components/chat/ChatShell.tsx
index 280e2d10b8c..03d91bb13bc 100644
--- a/ui/litellm-dashboard/src/components/chat/ChatShell.tsx
+++ b/ui/litellm-dashboard/src/components/chat/ChatShell.tsx
@@ -192,9 +192,6 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
))}
-
- Chat is a pre-v0 preview and may change unexpectedly. Do not use in production.
-
diff --git a/ui/litellm-dashboard/src/components/chat/backgroundChat.test.ts b/ui/litellm-dashboard/src/components/chat/backgroundChat.test.ts
new file mode 100644
index 00000000000..fd3d51883ff
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat/backgroundChat.test.ts
@@ -0,0 +1,177 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { Response as ModelResponse } from "openai/resources/responses/responses";
+import { backgroundResponseUpdate, cancelBackgroundChat, watchBackgroundChat } from "./backgroundChat";
+
+vi.mock("@/components/networking", () => ({ getGatewayBaseUrl: () => "http://localhost:4000/v1" }));
+
+afterEach(() => vi.unstubAllGlobals());
+
+it("should restore text, reasoning, tool results and usage from a completed response", () => {
+ const response = {
+ id: "litellm_poll_example",
+ status: "completed",
+ output: [
+ { type: "reasoning", summary: [{ type: "summary_text", text: "Check the inventory" }] },
+ {
+ type: "mcp_call",
+ id: "call_lookup",
+ name: "lookup",
+ arguments: "{}",
+ output: "12 widgets",
+ server_label: "inventory",
+ },
+ { type: "message", content: [{ type: "output_text", text: "There are 12 widgets." }] },
+ ],
+ usage: { input_tokens: 4, output_tokens: 9, total_tokens: 13, output_tokens_details: { reasoning_tokens: 2 } },
+ } as ModelResponse;
+ const update = backgroundResponseUpdate(response);
+ expect(update.content).toBe("There are 12 widgets.");
+ expect(update.reasoningContent).toBe("Check the inventory");
+ expect(update.mcpEvents?.[0].item?.output).toBe("12 widgets");
+ const usage = { promptTokens: 4, completionTokens: 9, totalTokens: 13, reasoningTokens: 2 };
+ expect(update.usage).toMatchObject(usage);
+ expect(update.backgroundRun).toEqual({ id: response.id, status: "completed" });
+});
+
+describe("background response lifecycle", () => {
+ it("should detach polling without cancelling the server run", async () => {
+ const controller = new AbortController();
+ const fetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
+ Response.json({ id: "litellm_poll_example", status: "in_progress", output: [] }),
+ );
+ vi.stubGlobal("fetch", fetch);
+ const onUpdate = vi.fn(() => controller.abort());
+ const polling = {
+ accessToken: "test-token",
+ responseId: "litellm_poll_example",
+ signal: controller.signal,
+ onUpdate,
+ };
+ await watchBackgroundChat(polling);
+ expect(onUpdate).toHaveBeenCalledOnce();
+ expect(fetch).toHaveBeenCalledOnce();
+ expect(fetch.mock.calls[0][1]?.method).toBe("GET");
+ });
+
+ it("should mark an expired run unavailable without clearing its partial answer", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => Response.json({ error: { message: "Expired" } }, { status: 404 })),
+ );
+ const onUpdate = vi.fn();
+ const polling = {
+ accessToken: "test-token",
+ responseId: "litellm_poll_expired",
+ signal: new AbortController().signal,
+ onUpdate,
+ };
+ await watchBackgroundChat(polling);
+ expect(onUpdate).toHaveBeenCalledWith({
+ backgroundRun: { id: "litellm_poll_expired", status: "expired" },
+ backgroundError: "This saved run is no longer available. Its result may have expired on the server.",
+ });
+ });
+
+ it("should cancel explicitly through the server and preserve returned partial output", async () => {
+ const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const request = input instanceof Request ? input : new Request(input, init);
+ expect(request.method).toBe("POST");
+ expect(new URL(request.url).pathname).toBe("/v1/responses/litellm_poll_example/cancel");
+ return Response.json({
+ id: "litellm_poll_example",
+ status: "cancelled",
+ output: [{ type: "message", content: [{ type: "output_text", text: "Partial answer" }] }],
+ });
+ });
+ vi.stubGlobal("fetch", fetch);
+ expect(await cancelBackgroundChat("test-token", "litellm_poll_example")).toMatchObject({
+ content: "Partial answer",
+ backgroundRun: { id: "litellm_poll_example", status: "cancelled" },
+ });
+ });
+});
+
+it.each([
+ [0.125, 0.125],
+ ["0.125", 0.125],
+ [0, 0],
+ ["invalid", undefined],
+])("should preserve valid returned cost %s", (cost, expected) => {
+ const response = {
+ id: "litellm_poll_cost",
+ status: "completed",
+ output: [],
+ usage: { input_tokens: 4, output_tokens: 9, total_tokens: 13, cost },
+ } as ModelResponse;
+ expect(backgroundResponseUpdate(response).usage?.cost).toBe(expected);
+});
+
+it("should keep polling through unfinished output items until the answer and tools arrive", async () => {
+ let polls = 0;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => {
+ polls++;
+ return Response.json({
+ id: "litellm_poll_partial",
+ status: polls === 1 ? "in_progress" : "completed",
+ output:
+ polls === 1
+ ? [
+ { type: "message", id: "message_1" },
+ { type: "mcp_list_tools", id: "tools_1", server_label: "inventory" },
+ ]
+ : [
+ { type: "message", content: [{ type: "output_text", text: "12 widgets" }] },
+ { type: "mcp_list_tools", server_label: "inventory", tools: [{ name: "lookup", input_schema: {} }] },
+ ],
+ });
+ }),
+ );
+ const onUpdate = vi.fn();
+ const polling = {
+ accessToken: "test-token",
+ responseId: "litellm_poll_partial",
+ signal: new AbortController().signal,
+ onUpdate,
+ };
+ await watchBackgroundChat(polling);
+ expect(polls).toBe(2);
+ expect(onUpdate.mock.lastCall?.[0]).toMatchObject({
+ content: "12 widgets",
+ backgroundRun: { status: "completed" },
+ mcpEvents: [{ item: { tools: [{ name: "lookup" }] } }],
+ });
+});
+
+it("should restore server timings from polling without counting time away from the chat", async () => {
+ const response = {
+ id: "litellm_poll_timing",
+ status: "completed",
+ created_at: 1000,
+ output: [],
+ litellm_response_timing: { time_to_first_token_ms: 250, total_latency_ms: 12500 },
+ };
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => Response.json(response)),
+ );
+ const onUpdate = vi.fn();
+ const polling = {
+ accessToken: "test-token",
+ responseId: "litellm_poll_timing",
+ signal: new AbortController().signal,
+ onUpdate,
+ };
+ await watchBackgroundChat(polling);
+ expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ timeToFirstToken: 250, totalLatency: 12500 }));
+});
+
+it.each(["queued", "completed"] as const)(
+ "should not invent timings for a %s response without measurements",
+ (status) => {
+ const update = backgroundResponseUpdate({ id: "litellm_poll_legacy", status, output: [] } as ModelResponse);
+ expect(update).not.toHaveProperty("timeToFirstToken");
+ expect(update).not.toHaveProperty("totalLatency");
+ },
+);
diff --git a/ui/litellm-dashboard/src/components/chat/backgroundChat.ts b/ui/litellm-dashboard/src/components/chat/backgroundChat.ts
new file mode 100644
index 00000000000..177d25f2e16
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat/backgroundChat.ts
@@ -0,0 +1,138 @@
+import OpenAI from "openai";
+import type { Response } from "openai/resources/responses/responses";
+import { getGatewayBaseUrl } from "@/components/networking";
+import { parseUsageCost } from "@/components/llm_calls/usage_cost";
+import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
+import type { AssistantMessageUpdate, ChatBackgroundRun, MCPEvent } from "./types";
+
+export const isBackgroundRunPending = (run?: ChatBackgroundRun): boolean =>
+ run?.status === "queued" || run?.status === "in_progress";
+
+type TimedResponse = Response & {
+ litellm_response_timing?: {
+ time_to_first_token_ms?: number;
+ total_latency_ms?: number;
+ };
+};
+
+export function backgroundResponseUpdate(response: TimedResponse): AssistantMessageUpdate {
+ const output = response.output ?? [];
+ const content = output
+ .filter((item) => item.type === "message")
+ .flatMap((item) => item.content ?? [])
+ .map((part) => (part.type === "output_text" ? part.text : part.refusal))
+ .join("");
+ const reasoningContent = output
+ .filter((item) => item.type === "reasoning")
+ .flatMap((item) => item.summary ?? [])
+ .map((part) => part.text)
+ .join("\n\n");
+ const mcpEvents: MCPEvent[] = output.flatMap
((item) => {
+ if (item.type === "mcp_call") {
+ return [{ type: "response.output_item.done", item: { ...item, output: item.output ?? undefined } }];
+ }
+ if (item.type === "mcp_list_tools") {
+ return [
+ {
+ type: "response.output_item.done",
+ item: {
+ ...item,
+ tools: (item.tools ?? []).map((tool) => ({
+ name: tool.name,
+ input_schema: tool.input_schema,
+ description: tool.description ?? "",
+ })),
+ },
+ },
+ ];
+ }
+ return [];
+ });
+ const status = response.status ?? "in_progress";
+ const errors: Partial> = {
+ failed: response.error?.message || "The background run failed.",
+ incomplete: "The run ended before completing its answer.",
+ };
+ const backgroundError = errors[status];
+ const cost = response.usage && "cost" in response.usage ? parseUsageCost(response.usage.cost) : undefined;
+ const timing = response.litellm_response_timing;
+ return {
+ content,
+ reasoningContent,
+ mcpEvents,
+ backgroundRun: { id: response.id, status },
+ backgroundError,
+ ...(timing?.time_to_first_token_ms !== undefined && { timeToFirstToken: timing.time_to_first_token_ms }),
+ ...(timing?.total_latency_ms !== undefined && { totalLatency: timing.total_latency_ms }),
+ ...(response.usage && {
+ usage: {
+ promptTokens: response.usage.input_tokens,
+ completionTokens: response.usage.output_tokens,
+ totalTokens: response.usage.total_tokens,
+ reasoningTokens: response.usage.output_tokens_details?.reasoning_tokens,
+ ...extractPromptCacheTokens(response.usage),
+ ...(cost !== undefined ? { cost } : {}),
+ },
+ }),
+ };
+}
+
+const backgroundClient = (accessToken: string) =>
+ new OpenAI({
+ apiKey: accessToken,
+ baseURL: getGatewayBaseUrl(),
+ dangerouslyAllowBrowser: true,
+ });
+
+function waitForPoll(signal: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ const abort = () => {
+ clearTimeout(timer);
+ reject(signal.reason);
+ };
+ const timer = setTimeout(() => {
+ signal.removeEventListener("abort", abort);
+ resolve();
+ }, 1000);
+ signal.addEventListener("abort", abort, { once: true });
+ if (signal.aborted) abort();
+ });
+}
+
+export async function watchBackgroundChat({
+ accessToken,
+ responseId,
+ signal,
+ onUpdate,
+}: {
+ accessToken: string;
+ responseId: string;
+ signal: AbortSignal;
+ onUpdate: (update: AssistantMessageUpdate) => void;
+}): Promise {
+ const client = backgroundClient(accessToken);
+ while (!signal.aborted) {
+ try {
+ const response = await client.responses.retrieve(responseId, { stream: false }, { signal });
+ if (signal.aborted) return;
+ onUpdate(backgroundResponseUpdate(response));
+ if (!isBackgroundRunPending({ id: response.id, status: response.status ?? "in_progress" })) return;
+ await waitForPoll(signal);
+ } catch (error) {
+ if (signal.aborted) return;
+ if (error instanceof OpenAI.APIError && error.status === 404) {
+ onUpdate({
+ backgroundRun: { id: responseId, status: "expired" },
+ backgroundError: "This saved run is no longer available. Its result may have expired on the server.",
+ });
+ return;
+ }
+ throw error;
+ }
+ }
+}
+
+export async function cancelBackgroundChat(accessToken: string, responseId: string): Promise {
+ const response = await backgroundClient(accessToken).responses.cancel(responseId);
+ return backgroundResponseUpdate(response);
+}
diff --git a/ui/litellm-dashboard/src/components/chat/runChatRequest.ts b/ui/litellm-dashboard/src/components/chat/runChatRequest.ts
index 157363d2461..9e8016b608e 100644
--- a/ui/litellm-dashboard/src/components/chat/runChatRequest.ts
+++ b/ui/litellm-dashboard/src/components/chat/runChatRequest.ts
@@ -1,5 +1,6 @@
import OpenAI from "openai";
import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api";
+import { backgroundResponseUpdate } from "./backgroundChat";
import { generateChatImages } from "./imageHelpers";
import { toResponsesMessages } from "./attachmentHelpers";
import type { AssistantMessageUpdate, ChatMessage, MCPEvent } from "./types";
@@ -15,6 +16,7 @@ interface ChatRequestOptions {
"role" | "content" | "attachments" | "generatedImages" | "imageGeneration" | "mcpEvents"
>[];
previousResponseId?: string | null;
+ background?: boolean;
mcpServers?: string[];
onResponseId: (id: string | null) => void;
onUpdate: (update: AssistantMessageUpdate) => void;
@@ -43,6 +45,7 @@ export async function runChatRequest({
signal,
messages,
previousResponseId,
+ background = false,
mcpServers,
onResponseId,
onUpdate,
@@ -94,6 +97,14 @@ export async function runChatRequest({
undefined,
true,
(totalLatency) => onUpdate({ totalLatency }),
+ undefined,
+ undefined,
+ background
+ ? (response) => {
+ onResponseId(null);
+ onUpdate(backgroundResponseUpdate(response));
+ }
+ : undefined,
);
signal.throwIfAborted();
} catch (error: unknown) {
diff --git a/ui/litellm-dashboard/src/components/chat/types.ts b/ui/litellm-dashboard/src/components/chat/types.ts
index 25b232e40c4..e98b61e2fe4 100644
--- a/ui/litellm-dashboard/src/components/chat/types.ts
+++ b/ui/litellm-dashboard/src/components/chat/types.ts
@@ -25,6 +25,11 @@ export interface ChatImageGeneration {
prompt: string;
}
+export interface ChatBackgroundRun {
+ id: string;
+ status: "queued" | "in_progress" | "completed" | "failed" | "cancelled" | "incomplete" | "expired";
+}
+
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "tool";
@@ -33,6 +38,8 @@ export interface ChatMessage {
generatedImages?: ChatImageAttachment[];
imageGeneration?: ChatImageGeneration;
reasoningContent?: string;
+ backgroundRun?: ChatBackgroundRun;
+ backgroundError?: string;
/** MCP tool events that occurred during this assistant turn, in order. */
mcpEvents?: MCPEvent[];
toolName?: string;
@@ -55,6 +62,8 @@ export type AssistantMessageUpdate = Partial<
| "usage"
| "generatedImages"
| "imageGeneration"
+ | "backgroundRun"
+ | "backgroundError"
>
>;
diff --git a/ui/litellm-dashboard/src/components/chat/useBackgroundChat.ts b/ui/litellm-dashboard/src/components/chat/useBackgroundChat.ts
new file mode 100644
index 00000000000..c0d1c43bcde
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat/useBackgroundChat.ts
@@ -0,0 +1,62 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { cancelBackgroundChat, isBackgroundRunPending, watchBackgroundChat } from "./backgroundChat";
+import type { AssistantMessageUpdate, ChatBackgroundRun } from "./types";
+
+export function useBackgroundChat(
+ conversationId: string | null,
+ run: ChatBackgroundRun | undefined,
+ accessToken: string,
+ updateMessage: (conversationId: string, update: AssistantMessageUpdate) => void,
+) {
+ const [failure, setFailure] = useState<{ responseId: string; retry: number; message: string } | null>(null);
+ const [retry, setRetry] = useState(0);
+ const [cancellingId, setCancellingId] = useState(null);
+ const controllerRef = useRef(null);
+ const pending = isBackgroundRunPending(run);
+ const responseId = run?.id;
+ const cancelling = Boolean(responseId && cancellingId === responseId);
+ const connectionError =
+ pending && failure?.responseId === responseId && failure?.retry === retry ? failure.message : null;
+
+ useEffect(() => {
+ if (!conversationId || !responseId || !pending) return;
+ const controller = new AbortController();
+ controllerRef.current = controller;
+ const polling = {
+ accessToken,
+ responseId,
+ signal: controller.signal,
+ onUpdate: (update: AssistantMessageUpdate) => updateMessage(conversationId, update),
+ };
+ void watchBackgroundChat(polling).catch(() => {
+ if (!controller.signal.aborted)
+ setFailure({ responseId, retry, message: "Connection lost. Your run may still be running on the server." });
+ });
+ return () => {
+ controller.abort();
+ if (controllerRef.current === controller) controllerRef.current = null;
+ };
+ }, [conversationId, responseId, pending, accessToken, updateMessage, retry]);
+
+ const reconnect = useCallback(() => setRetry((value) => value + 1), []);
+
+ const cancel = useCallback(async () => {
+ if (!conversationId || !responseId || cancelling) return;
+ const controller = controllerRef.current;
+ controller?.abort();
+ setCancellingId(responseId);
+ try {
+ const update = await cancelBackgroundChat(accessToken, responseId);
+ updateMessage(conversationId, update);
+ if (controllerRef.current === controller) reconnect();
+ } catch {
+ if (controllerRef.current === controller) {
+ setFailure({ responseId, retry, message: "Could not stop the run. Reconnect and try Stop again." });
+ }
+ } finally {
+ setCancellingId(null);
+ }
+ }, [conversationId, responseId, accessToken, cancelling, reconnect, retry, updateMessage]);
+
+ return { pending, connectionError, cancelling, reconnect, cancel };
+}
diff --git a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx
index d38db631c6e..cac91cf92d4 100644
--- a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx
+++ b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx
@@ -9,6 +9,7 @@ export interface ModelGroup {
mode?: string;
supports_reasoning?: boolean;
supports_fast_mode?: boolean;
+ supports_background_chat?: boolean;
supported_reasoning_efforts?: string[] | null;
}
@@ -20,6 +21,7 @@ interface AvailableModel {
mode?: string | null;
supports_reasoning?: boolean | null;
supports_fast_mode?: boolean | null;
+ supports_background_chat?: boolean | null;
supported_reasoning_efforts?: string[] | null;
}
@@ -30,6 +32,7 @@ const toModelGroup = (item: AvailableModel): ModelGroup => {
...(item.providers && { providers: item.providers }),
...(item.mode && { mode: item.mode }),
...(item.supports_reasoning === true && { supports_reasoning: true }),
+ ...(item.supports_background_chat === true && { supports_background_chat: true }),
...(item.supports_fast_mode === true && { supports_fast_mode: true }),
...(item.supported_reasoning_efforts !== undefined && {
supported_reasoning_efforts: item.supported_reasoning_efforts,
diff --git a/ui/litellm-dashboard/src/components/llm_calls/responses_api.tsx b/ui/litellm-dashboard/src/components/llm_calls/responses_api.tsx
index ce1a8aab0c5..ef737c22cdb 100644
--- a/ui/litellm-dashboard/src/components/llm_calls/responses_api.tsx
+++ b/ui/litellm-dashboard/src/components/llm_calls/responses_api.tsx
@@ -1,6 +1,10 @@
import { observeResponseHeaders, type ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
import openai from "openai";
-import type { ResponseFunctionToolCall, ResponseInputItem } from "openai/resources/responses/responses";
+import type {
+ Response as OpenAIResponse,
+ ResponseFunctionToolCall,
+ ResponseInputItem,
+} from "openai/resources/responses/responses";
import { MessageType } from "../chat_ui/types";
import { TokenUsage } from "../chat_ui/ResponseMetrics";
import { getGatewayBaseUrl, getProxyBaseUrl } from "@/components/networking";
@@ -110,6 +114,7 @@ export async function makeOpenAIResponsesRequest(
onTotalLatency?: (latency: number) => void,
customHeaders?: CustomHeaders,
onResponseHeaders?: ResponseHeadersListener,
+ onBackgroundResponse?: (response: OpenAIResponse) => void,
) {
if (!accessToken) {
throw new Error("Virtual Key is required");
@@ -239,6 +244,16 @@ export async function makeOpenAIResponsesRequest(
...(tools.length > 0 ? { tools, tool_choice: "auto" } : {}),
};
+ if (onBackgroundResponse) {
+ const response = await client.post("/responses", {
+ body: { ...requestBody, background: true, stream: false },
+ signal,
+ maxRetries: 0,
+ });
+ onBackgroundResponse(response);
+ return response;
+ }
+
// Create request to OpenAI responses API
// Use 'any' type to avoid TypeScript issues with the experimental API
const response = streamingEnabled
diff --git a/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx
index f93e01eb583..32e590017b0 100644
--- a/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx
@@ -19,7 +19,6 @@ vi.mock("../../utils/dataUtils", () => ({
}));
vi.mock("./networking", () => ({
- modelInfoV1Call: vi.fn(),
credentialGetCall: vi.fn(),
credentialListCall: vi.fn(),
getGuardrailsList: vi.fn(),
@@ -56,7 +55,6 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled", () =>
}));
const mockToast = vi.mocked(toast);
-const mockModelInfoV1Call = vi.mocked(networking.modelInfoV1Call);
const mockCredentialGetCall = vi.mocked(networking.credentialGetCall);
const mockCredentialListCall = vi.mocked(networking.credentialListCall);
const mockGetGuardrailsList = vi.mocked(networking.getGuardrailsList);
@@ -134,10 +132,6 @@ describe("ModelInfoView", () => {
error: null,
});
- mockModelInfoV1Call.mockResolvedValue({
- data: [defaultModelData],
- });
-
mockCredentialGetCall.mockResolvedValue({
credential_name: "test-credential",
credential_values: {},
@@ -394,7 +388,6 @@ describe("ModelInfoView", () => {
model_info: { ...defaultModelData.model_info, mode: "chat", key: "anthropic.claude-haiku-4-5" },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [displayedModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [displayedModel] });
render(, { wrapper });
await waitFor(() => {
@@ -869,7 +862,6 @@ describe("ModelInfoView", () => {
const renderWithPtuModel = () => {
mockUseModelsInfo.mockReturnValue({ data: { data: [ptuModelData] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [ptuModelData] });
return render(, { wrapper });
};
@@ -967,7 +959,6 @@ describe("ModelInfoView", () => {
model_info: { ...defaultModelData.model_info, team_id: "team-1", input_cost_per_token: 0.0000003 },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [seededModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [seededModel] });
const user = userEvent.setup();
render(, { wrapper });
@@ -1008,7 +999,6 @@ describe("ModelInfoView", () => {
model_info: { ...ptuModelData.model_info, cost_per_ptu_per_hour: null, ptu_effective_from: null },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [halfSetPtuModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [halfSetPtuModel] });
const user = userEvent.setup();
render(, { wrapper });
@@ -1057,7 +1047,6 @@ describe("ModelInfoView", () => {
model_info: { ...ptuModelData.model_info, ptu_effective_from: null, ptu_effective_to: null },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [undatedPtuModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [undatedPtuModel] });
const user = userEvent.setup();
render(, { wrapper });
@@ -1201,7 +1190,6 @@ describe("ModelInfoView", () => {
isLoading: false,
error: null,
});
- mockModelInfoV1Call.mockResolvedValue({ data: [maskedModelData] });
const user = userEvent.setup();
render(, { wrapper });
@@ -1508,7 +1496,6 @@ describe("ModelInfoView", () => {
beforeEach(() => {
mockUseModelsInfo.mockReturnValue({ data: { data: [teamModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [teamModel] });
});
const readRawJson = async (user: ReturnType) => {
@@ -1555,7 +1542,6 @@ describe("ModelInfoView", () => {
it("shows Not Set and no team_alias for a model without a team", async () => {
mockUseModelsInfo.mockReturnValue({ data: { data: [defaultModelData] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [defaultModelData] });
mockUseTeams.mockReturnValue({
data: [{ team_id: "team-1", team_alias: "alpha" }],
isLoading: false,
@@ -1849,7 +1835,6 @@ describe("ModelInfoView", () => {
beforeEach(() => {
mockUseModelsInfo.mockReturnValue({ data: { data: [withCacheWriteTiers] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [withCacheWriteTiers] });
});
it("shows both configured cache write rates per million tokens", async () => {
@@ -1926,7 +1911,6 @@ describe("ModelInfoView", () => {
model_info: { ...defaultModelData.model_info, team_id: "team-1" },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [teamModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [teamModel] });
const user = userEvent.setup();
await enterEditMode(user);
@@ -1952,7 +1936,6 @@ describe("ModelInfoView", () => {
model_info: { ...defaultModelData.model_info, team_id: "team-1" },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [teamModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [teamModel] });
const user = userEvent.setup();
await enterEditMode(user);
@@ -1992,7 +1975,6 @@ describe("ModelInfoView", () => {
model_info: { ...defaultModelData.model_info, team_id: "team-1" },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [teamModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [teamModel] });
const user = userEvent.setup();
render(, { wrapper });
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
@@ -2069,7 +2051,6 @@ describe("ModelInfoView", () => {
litellm_params: litellmParamsWithoutCredential,
};
mockUseModelsInfo.mockReturnValue({ data: { data: [modelWithoutCredential] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [modelWithoutCredential] });
const user = userEvent.setup();
await enterEditMode(user);
@@ -2150,7 +2131,6 @@ describe("ModelInfoView", () => {
litellm_params: { ...defaultModelData.litellm_params, model: "openai/gpt-4*" },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [wildcard] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [wildcard] });
mockUseModelHub.mockReturnValue({
data: { data: [{ model_group: "openai/gpt-4o", providers: ["openai"] }] },
isLoading: false,
@@ -2207,7 +2187,6 @@ describe("ModelInfoView", () => {
litellm_params: { ...defaultModelData.litellm_params, cache_control_injection_points: points },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [data] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [data] });
};
it("omits the key when the deployment has none and the operator leaves the toggle alone", async () => {
@@ -2330,7 +2309,6 @@ describe("ModelInfoView", () => {
litellm_params: { ...defaultModelData.litellm_params, cache_read_input_token_cost: 0.000001 },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [withCacheRead] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [withCacheRead] });
const user = userEvent.setup();
await enterEditMode(user);
setInputCost("2");
@@ -2365,7 +2343,6 @@ describe("ModelInfoView", () => {
},
};
mockUseModelsInfo.mockReturnValue({ data: { data: [resolved] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [resolved] });
const user = userEvent.setup();
await enterEditMode(user);
setInputCost("5");
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index c13052be245..994c2948346 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -48,7 +48,7 @@ export const getAutoRouterClassifierDefaultPromptCall = async (
}
};
-export type AssembledPromptTierSource =
+type AssembledPromptTierSource =
| { tierDefinitions: { name: string; description?: string }[] }
| { tierLabels?: Record; classificationRubric?: string };
@@ -127,16 +127,6 @@ import type {
SubscriptionUsageResponse,
} from "@/app/(dashboard)/usage/_components/components/SubscriptionCost/types";
import type { RoutingDecision } from "./logs/detail/RoutingDecisionCard";
-import type {
- SpanDetail,
- SpanErrorPage,
- SpanErrorQuery,
- SpanQuery,
- Trace,
- TraceDetailQuery,
- TraceListQuery,
- TracePage,
-} from "@litellm/lens-ui";
import {
createApiClient,
deriveErrorMessage,
@@ -541,7 +531,7 @@ export const apiClient = createApiClient({
onError: handleError,
});
-export const gatewayApiClient = createApiClient({
+const gatewayApiClient = createApiClient({
getBaseUrl: getGatewayBaseUrl,
getAuthHeaderName: getGlobalLitellmHeaderName,
onError: handleError,
@@ -1725,37 +1715,6 @@ export const modelInfoCall = async (
}
};
-export const modelInfoV1Call = async (accessToken: string, modelId: string) => {
- /**
- * Get all models on proxy
- */
- try {
- let url = proxyBaseUrl ? `${proxyBaseUrl}/v1/model/info` : `/v1/model/info`;
- url += `?litellm_model_id=${modelId}`;
-
- const response = await fetch(url, {
- method: "GET",
- headers: {
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- "Content-Type": "application/json",
- },
- });
-
- if (!response.ok) {
- const errorData = await response.json();
- const errorMessage = deriveErrorMessage(errorData);
- handleError(errorMessage);
- throw new Error(errorMessage);
- }
-
- const data = await response.json();
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
export const modelHubPublicModelsCall = async () => {
const url = proxyBaseUrl ? `${proxyBaseUrl}/public/model_hub` : `/public/model_hub`;
const response = await fetch(url, {
@@ -1906,66 +1865,6 @@ export const modelAvailableCall = async (
}
};
-export const teamSpendLogsCall = async (accessToken: string) => {
- try {
- const data = await apiClient.get(`/global/spend/teams`, { accessToken });
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
-export const tagsSpendLogsCall = async (
- accessToken: string,
- startTime: string | undefined,
- endTime: string | undefined,
- tags: string[] | undefined,
-) => {
- try {
- let url = proxyBaseUrl ? `${proxyBaseUrl}/global/spend/tags` : `/global/spend/tags`;
-
- if (startTime && endTime) {
- url = `${url}?start_date=${startTime}&end_date=${endTime}`;
- }
-
- // if tags, convert the list to a comma separated string
- if (tags) {
- url += `&tags=${tags.join(",")}`;
- }
-
- const response = await fetch(`${url}`, {
- method: "GET",
- headers: {
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- "Content-Type": "application/json",
- },
- });
- if (!response.ok) {
- const errorData = await response.json();
- const errorMessage = deriveErrorMessage(errorData);
- handleError(errorMessage);
- throw new Error(errorMessage);
- }
-
- const data = await response.json();
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
-export const allTagNamesCall = async (accessToken: string) => {
- try {
- const data = await apiClient.get(`/global/spend/all_tag_names`, { accessToken });
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
export const userFilterUICall = async (accessToken: string, params: URLSearchParams) => {
try {
return await apiClient.get(`/user/filter/ui`, {
@@ -2081,214 +1980,6 @@ export const uiSpendLogsCall = async ({
}
};
-/**
- * Agent tracing. All three respond 501 `{detail}` when `general_settings.tracing` is not
- * configured; callers can detect that through the thrown `ApiError`'s `status`.
- */
-export const agentTraceListCall = async ({
- accessToken,
- startMs,
- endMs,
- cursor,
-}: {
- accessToken: string;
- startMs: number;
- endMs: number;
- cursor?: string | null;
-}): Promise => {
- const query = { start_ms: startMs, end_ms: endMs, cursor: cursor ?? undefined } satisfies TraceListQuery;
- return apiClient.get(`/v1/traces`, { accessToken, query });
-};
-
-export const sendOtlpTraceCall = async (accessToken: string, exportRequest: object): Promise =>
- apiClient.post(`/v1/traces`, { accessToken, body: exportRequest });
-
-export const agentTraceCall = async (
- accessToken: string,
- traceId: string,
- traceRef?: string,
- cursor?: string | null,
-): Promise =>
- apiClient.get(`/v1/traces/${encodeURIComponent(traceId)}`, {
- accessToken,
- query: { trace_ref: traceRef || undefined, cursor: cursor ?? undefined, page_size: 200 } satisfies TraceDetailQuery,
- });
-
-export const agentTraceSpanCall = async (
- accessToken: string,
- traceId: string,
- spanId: string,
- traceRef?: string,
-): Promise =>
- apiClient.get(`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}`, {
- accessToken,
- query: { trace_ref: traceRef || undefined } satisfies SpanQuery,
- });
-
-export const agentTraceSpanErrorCall = async (
- accessToken: string,
- traceId: string,
- spanId: string,
- options: { traceRef?: string; cursor?: string | null },
-): Promise =>
- apiClient.get(`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}/error`, {
- accessToken,
- query: { trace_ref: options.traceRef || undefined, cursor: options.cursor || undefined } satisfies SpanErrorQuery,
- });
-
-export const adminSpendLogsCall = async (accessToken: string) => {
- try {
- const data = await apiClient.get(`/global/spend/logs`, { accessToken });
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
-export const adminTopKeysCall = async (accessToken: string) => {
- try {
- let url = proxyBaseUrl ? `${proxyBaseUrl}/global/spend/keys?limit=5` : `/global/spend/keys?limit=5`;
-
- const response = await fetch(url, {
- method: "GET",
- headers: {
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- "Content-Type": "application/json",
- },
- });
- if (!response.ok) {
- const errorData = await response.json();
- const errorMessage = deriveErrorMessage(errorData);
- handleError(errorMessage);
- throw new Error(errorMessage);
- }
-
- const data = await response.json();
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
-export const adminTopEndUsersCall = async (
- accessToken: string,
- keyToken: string | null,
- startTime: string | undefined,
- endTime: string | undefined,
-) => {
- try {
- const body = keyToken
- ? { api_key: keyToken, startTime: startTime, endTime: endTime }
- : { startTime: startTime, endTime: endTime };
-
- const data = await apiClient.post(`/global/spend/end_users`, { accessToken, body });
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
-export const adminspendByProvider = async (
- accessToken: string,
- startTime: string | undefined,
- endTime: string | undefined,
-) => {
- try {
- const data = await apiClient.get(`/global/spend/provider`, {
- accessToken,
- query: {
- ...(startTime && endTime ? { start_date: startTime, end_date: endTime } : {}),
- },
- });
- return data;
- } catch (error) {
- console.error("Failed to fetch spend data:", error);
- throw error;
- }
-};
-
-export const adminGlobalActivity = async (
- accessToken: string,
- startTime: string | undefined,
- endTime: string | undefined,
-) => {
- try {
- const data = await apiClient.get(`/global/activity`, {
- accessToken,
- query: startTime && endTime ? { start_date: startTime, end_date: endTime } : undefined,
- });
- return data;
- } catch (error) {
- console.error("Failed to fetch spend data:", error);
- throw error;
- }
-};
-
-export const adminGlobalActivityPerModel = async (
- accessToken: string,
- startTime: string | undefined,
- endTime: string | undefined,
-) => {
- try {
- let url = proxyBaseUrl ? `${proxyBaseUrl}/global/activity/model` : `/global/activity/model`;
-
- if (startTime && endTime) {
- url += `?start_date=${startTime}&end_date=${endTime}`;
- }
-
- const requestOptions = {
- method: "GET",
- headers: {
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- },
- };
-
- const response = await fetch(url, requestOptions);
-
- if (!response.ok) {
- const errorData = await response.json();
- const errorMessage = deriveErrorMessage(errorData);
- handleError(errorMessage);
- throw new Error(errorMessage);
- }
-
- const data = await response.json();
- return data;
- } catch (error) {
- console.error("Failed to fetch spend data:", error);
- throw error;
- }
-};
-
-export const adminTopModelsCall = async (accessToken: string) => {
- try {
- let url = proxyBaseUrl ? `${proxyBaseUrl}/global/spend/models?limit=5` : `/global/spend/models?limit=5`;
-
- const response = await fetch(url, {
- method: "GET",
- headers: {
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- "Content-Type": "application/json",
- },
- });
- if (!response.ok) {
- const errorData = await response.json();
- const errorMessage = deriveErrorMessage(errorData);
- handleError(errorMessage);
- throw new Error(errorMessage);
- }
-
- const data = await response.json();
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
export const testConnectionRequest = async (
accessToken: string,
litellm_params: Record,
@@ -2421,7 +2112,7 @@ export const testAutoRouterRouting = async (
}
};
-export interface ComplexityRouterConfigValidation {
+interface ComplexityRouterConfigValidation {
valid: boolean;
error?: string | null;
}
@@ -2733,7 +2424,7 @@ export interface UserConnectionStartResponse {
flow_handle: string;
}
-export type UserConnectionPollStatus = "pending" | "slow_down" | "expired" | "denied" | "no_copilot_seat" | "connected";
+type UserConnectionPollStatus = "pending" | "slow_down" | "expired" | "denied" | "no_copilot_seat" | "connected";
export interface UserConnectionPollResponse {
status: UserConnectionPollStatus;
@@ -3697,35 +3388,6 @@ export const getProxyUISettings = async (accessToken: string) => {
}
};
-export const getUISettings = async (accessToken: string) => {
- /**
- * Get UI-specific configuration flags from the database
- */
- try {
- const url = proxyBaseUrl ? `${proxyBaseUrl}/get/ui_settings` : `/get/ui_settings`;
- const response = await fetch(url, {
- method: "GET",
- headers: {
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- "Content-Type": "application/json",
- },
- });
-
- if (!response.ok) {
- const errorData = await response.json();
- const errorMessage = deriveErrorMessage(errorData);
- console.error("Failed to get UI settings:", errorMessage);
- return null;
- }
-
- const data = await response.json();
- return data;
- } catch (error) {
- console.error("Failed to get UI settings:", error);
- return null;
- }
-};
-
export const getMCPSemanticFilterSettings = async (accessToken: string) => {
/**
* Get MCP semantic filter configuration
@@ -4685,12 +4347,12 @@ export const createAgentCall = async (accessToken: string, agentData: any) => {
export type DiscoveredAgentCard = Agent["agent_card_params"];
-export type DiscoverAgentCardResponse = Omit & {
+type DiscoverAgentCardResponse = Omit & {
agent_card: DiscoveredAgentCard;
};
export type DiscoveryMode = components["schemas"]["DiscoveryMode"];
-export type DiscoverAgentCardOptions = Partial<
+type DiscoverAgentCardOptions = Partial<
Pick
>;
@@ -5613,7 +5275,7 @@ export const vectorStoreListCall = async (
}
};
-export interface IndexesListResponse {
+interface IndexesListResponse {
object: string;
data: VectorStoreIndex[];
}
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index e80df24a818..0dce821f862 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -5849,6 +5849,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/get/background_chat_settings": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Background Chat Settings
+ * @description Get saved background-response settings and the prerequisites for changing them.
+ */
+ get: operations["get_background_chat_settings_get_background_chat_settings_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/get/config/callbacks": {
parameters: {
query?: never;
@@ -18240,6 +18260,26 @@ export interface paths {
patch: operations["typesafe_proxy_route_typesafe__endpoint__patch"];
trace?: never;
};
+ "/update/background_chat_settings": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ /**
+ * Update Background Chat Settings
+ * @description Save background-response polling settings for the proxy configuration refresh.
+ */
+ patch: operations["update_background_chat_settings_update_background_chat_settings_patch"];
+ trace?: never;
+ };
"/update/default_team_settings": {
parameters: {
query?: never;
@@ -27157,6 +27197,37 @@ export interface components {
} & {
[key: string]: unknown;
};
+ /** BackgroundChatSettings */
+ BackgroundChatSettings: {
+ /**
+ * Enabled
+ * @description Enable cache-backed background Responses for Chat and API requests.
+ */
+ enabled: boolean;
+ };
+ /** BackgroundChatSettingsResponse */
+ BackgroundChatSettingsResponse: {
+ /**
+ * Can Save
+ * @description Whether database-backed configuration updates are available.
+ */
+ can_save: boolean;
+ /**
+ * Enabled
+ * @description Enable cache-backed background Responses for Chat and API requests.
+ */
+ enabled: boolean;
+ /**
+ * Managed By Config
+ * @description Whether the responses configuration is owned by the config file.
+ */
+ managed_by_config: boolean;
+ /**
+ * Redis Configured
+ * @description Whether this proxy has coordination Redis configured.
+ */
+ redis_configured: boolean;
+ };
/** BaseLitellmParams */
BaseLitellmParams: {
/**
@@ -38804,6 +38875,12 @@ export interface components {
supported_openai_params: string[] | null;
/** Supported Reasoning Efforts */
supported_reasoning_efforts?: string[] | null;
+ /**
+ * Supports Background Chat
+ * @description Whether cache-backed background Responses runs are enabled for this model group
+ * @default false
+ */
+ supports_background_chat: boolean;
/**
* Supports Fast Mode
* @default false
@@ -60982,6 +61059,26 @@ export interface operations {
};
};
};
+ get_background_chat_settings_get_background_chat_settings_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["BackgroundChatSettingsResponse"];
+ };
+ };
+ };
+ };
get_config_get_config_callbacks_get: {
parameters: {
query?: never;
@@ -76302,6 +76399,39 @@ export interface operations {
};
};
};
+ update_background_chat_settings_update_background_chat_settings_patch: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["BackgroundChatSettings"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["BackgroundChatSettingsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
update_default_team_settings_update_default_team_settings_patch: {
parameters: {
query?: never;