From c43a1addf252a343d2aa5eafc07f9cef985eef55 Mon Sep 17 00:00:00 2001 From: Nate Armstrong Date: Sat, 31 Oct 2026 12:54:23 -0700 Subject: [PATCH] refactor(router): extract PythonCore behind RouterCore and pass the router its config (#49119) * refactor(router): move litellm/router.py to litellm/router_python_core.py A pure rename with no content change, so git and review tools track the router body as a move. The next commit turns it into PythonCore and adds the Router facade back at litellm/router.py * refactor(router): extract PythonCore behind RouterCore and pass the router its config The routing logic moves unchanged from litellm/router.py into PythonCore in litellm/router_python_core.py. Router becomes a facade that builds a RouterConfig, picks a RouterCore with make_core and delegates every member to it. The core no longer reads or writes litellm.* directly: process settings come from RouterDynamicConfig, and inference calls, sleeps, callback lists and the global cache go through PythonHost. A CI check bans litellm.* in the router modules, and the proxy's construction sites build the config outside the router Settings are static: the constructor arguments and changeset values are copied when the router is built or the change applied, and a write to a setting attribute becomes apply(ChangeSet(assign=...)). A Router subclass's core class also subclasses the facade, so an override's super(Sub, self) works, and an instance-level patch of a core method is kept on the facade so nested patches unwind. tests/unit/test_router/core tests the core in isolation: ScriptedHost serves every host operation (scripted inference answers, recorded sleeps, settings, callbacks, cache), and make_core runs each test once per core in CORE_BUILDERS --- .../anthropic/messages/adapters/handler.py | 3 +- litellm/proxy/proxy_server.py | 64 +- litellm/proxy/route_llm_request.py | 6 +- litellm/responses/streaming_iterator.py | 5 + litellm/router.py | 17811 +--------------- litellm/router_api.py | 44 +- litellm/router_config.py | 157 + litellm/router_core.py | 56 + litellm/router_deprecated.py | 30 +- litellm/router_python_core.py | 17669 +++++++++++++++ litellm/router_python_host.py | 135 + .../code_coverage_tests/recursive_detector.py | 1 + .../router_litellm_globals_check.py | 54 + .../router_surface_check.py | 8 +- .../test_router_routing_groups.py | 8 +- .../test_router_tag_routing.py | 4 +- .../router_utils/test_cooldown_handlers.py | 4 +- .../test_kubernetes_pod_discovery.py | 3 +- tests/unit/test_conftest_isolation.py | 6 +- tests/unit/test_router/core/__init__.py | 0 tests/unit/test_router/core/conftest.py | 26 + tests/unit/test_router/core/scripted_host.py | 487 + tests/unit/test_router/core/test_endpoints.py | 445 + tests/unit/test_router/core/test_fallbacks.py | 756 + .../core/test_registry_changesets.py | 657 + .../core/test_retries_cooldowns.py | 832 + tests/unit/test_router/core/test_selection.py | 624 + tests/unit/test_router/test_router.py | 24 +- .../test_router/test_router_entry_points.py | 6 +- tests/unit/test_router/test_router_facade.py | 52 + .../test_router/test_router_helper_utils.py | 8 +- .../test_router_provider_endpoints.py | 2 +- tests/unit/test_router_silent_experiment.py | 4 +- tests/unit/test_router_weighted_failover.py | 12 +- 34 files changed, 22364 insertions(+), 17639 deletions(-) create mode 100644 litellm/router_config.py create mode 100644 litellm/router_core.py create mode 100644 litellm/router_python_core.py create mode 100644 litellm/router_python_host.py create mode 100644 tests/code_coverage_tests/router_litellm_globals_check.py create mode 100644 tests/unit/test_router/core/__init__.py create mode 100644 tests/unit/test_router/core/conftest.py create mode 100644 tests/unit/test_router/core/scripted_host.py create mode 100644 tests/unit/test_router/core/test_endpoints.py create mode 100644 tests/unit/test_router/core/test_fallbacks.py create mode 100644 tests/unit/test_router/core/test_registry_changesets.py create mode 100644 tests/unit/test_router/core/test_retries_cooldowns.py create mode 100644 tests/unit/test_router/core/test_selection.py create mode 100644 tests/unit/test_router/test_router_facade.py 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/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 634c709b3ca..b5f4a36f138 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 ( @@ -7407,20 +7409,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 +7916,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: 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/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/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/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..a3cffe8278a --- /dev/null +++ b/tests/unit/test_router/core/test_endpoints.py @@ -0,0 +1,445 @@ +""" +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}}]) + + 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 6e097e75463..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( @@ -24274,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 @@ -25669,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 == [] @@ -25689,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 @@ -29142,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() @@ -30988,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) 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(