From 7b292ccdf58c9ea663defedfc0aaeb16fbbbb39d Mon Sep 17 00:00:00 2001 From: Alan Ponnachan <85491837+AlanPonnachan@users.noreply.github.com> Date: Tue, 11 Nov 2025 09:03:01 +0530 Subject: [PATCH] router fallback for unknown models (#16419) --- litellm/router.py | 314 ++++++++++++++++++++---------- tests/test_litellm/test_router.py | 81 +++++++- 2 files changed, 292 insertions(+), 103 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 1489de8648..3537cacf0c 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -357,6 +357,7 @@ class Router: self.enable_pre_call_checks = enable_pre_call_checks self.enable_tag_filtering = enable_tag_filtering 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: @@ -708,9 +709,7 @@ class Router: routing_strategy == RoutingStrategy.LEAST_BUSY.value or routing_strategy == RoutingStrategy.LEAST_BUSY ): - self.leastbusy_logger = LeastBusyLoggingHandler( - router_cache=self.cache - ) + self.leastbusy_logger = LeastBusyLoggingHandler(router_cache=self.cache) ## add callback if isinstance(litellm.input_callback, list): litellm.input_callback.append(self.leastbusy_logger) # type: ignore @@ -774,34 +773,81 @@ class Router: 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.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.amoderation = self.factory_function( + litellm.amoderation, call_type="moderation" + ) + self.aanthropic_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.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_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") + 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.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_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" + ) def _initialize_specialized_endpoints(self): """Helper to initialize specialized router endpoints (vector store, OCR, search, video, container).""" from litellm.vector_stores.main import acreate, asearch, create, search - self.avector_store_search = self.factory_function(asearch, call_type="avector_store_search") - self.avector_store_create = self.factory_function(acreate, call_type="avector_store_create") - 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_search = self.factory_function( + asearch, call_type="avector_store_search" + ) + self.avector_store_create = self.factory_function( + acreate, call_type="avector_store_create" + ) + 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" + ) from litellm.google_genai import ( agenerate_content, @@ -809,16 +855,27 @@ class Router: 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") + + 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" + ) 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") @@ -834,15 +891,30 @@ class Router: 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_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.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") from litellm.containers import ( @@ -855,14 +927,31 @@ class Router: list_containers, retrieve_container, ) - 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") + + 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" + ) def initialize_router_endpoints(self): self._initialize_core_endpoints() @@ -2694,21 +2783,19 @@ class Router: self.fail_calls[model] += 1 raise e - async def _asearch_with_fallbacks( - self, original_function: Callable, **kwargs - ): + async def _asearch_with_fallbacks(self, original_function: Callable, **kwargs): """ 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 await SearchAPIRouter.async_search_with_fallbacks( router_instance=self, original_function=original_function, **kwargs, ) - + async def _asearch_with_fallbacks_helper( self, model: str, original_generic_function: Callable, **kwargs ): @@ -2717,7 +2804,7 @@ class Router: 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( router_instance=self, model=model, @@ -2755,11 +2842,9 @@ class Router: ) ) raise e - + def _add_deployment_model_to_endpoint_for_llm_passthrough_route( - self, kwargs: Dict[str, Any], - model: str, - model_name: str + self, kwargs: Dict[str, Any], model: str, model_name: str ) -> Dict[str, Any]: """ Add the deployment model to the endpoint for LLM passthrough route. @@ -2771,7 +2856,7 @@ class Router: # 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( @@ -2783,8 +2868,10 @@ class Router: except Exception: # If get_llm_provider fails, fall back to using model_name as-is replacement_model_name = model_name - - kwargs["endpoint"] = kwargs["endpoint"].replace(model, replacement_model_name) + + kwargs["endpoint"] = kwargs["endpoint"].replace( + model, replacement_model_name + ) return kwargs async def _ageneric_api_call_with_fallbacks_helper( @@ -2818,7 +2905,9 @@ class Router: model_name = 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) + self._add_deployment_model_to_endpoint_for_llm_passthrough_route( + kwargs=kwargs, model=model, model_name=model_name + ) ### get custom response = original_generic_function( **{ @@ -3620,7 +3709,7 @@ class Router: "aretrieve_container", "retrieve_container", "adelete_container", - "delete_container" + "delete_container", ] = "assistants", ): """ @@ -4384,6 +4473,19 @@ class Router: break return fallback_model_group + def _get_first_default_fallback(self) -> Optional[str]: + """ + Returns the first model from the default_fallbacks list, if it exists. + """ + if self.fallbacks is None: + return None + for fallback in self.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, @@ -4620,7 +4722,7 @@ class Router: try: exception = kwargs.get("exception", None) exception_status = getattr(exception, "status_code", "") - + # Cache litellm_params to avoid repeated dict lookups litellm_params = kwargs.get("litellm_params", {}) _model_info = litellm_params.get("model_info", {}) @@ -5269,7 +5371,7 @@ class Router: f"\nInitialized Model List {self.get_model_names()}" ) self.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 @@ -5494,13 +5596,13 @@ class Router: # 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.model_name_to_deployment_indices.items()): # Remove the deleted index if removal_idx in indices: indices.remove(removal_idx) - + # Decrement all indices greater than removal_idx updated_indices = [] for idx in indices: @@ -5508,7 +5610,7 @@ class Router: updated_indices.append(idx - 1) else: updated_indices.append(idx) - + # Update or remove the entry if len(updated_indices) > 0: self.model_name_to_deployment_indices[model_name] = updated_indices @@ -5527,13 +5629,13 @@ class Router: """ idx = len(self.model_list) self.model_list.append(model) - + # 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 = model.get("model_name") if model_name: @@ -5653,7 +5755,7 @@ class Router: 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 @@ -5771,7 +5873,7 @@ class Router: 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 @@ -5886,11 +5988,11 @@ class Router: 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: Optional[int] = None if _deployment_tpm is None: @@ -6266,12 +6368,12 @@ class Router: 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.model_name_to_deployment_indices.clear() - + for idx, model in enumerate(model_list): model_name = model.get("model_name") if model_name: @@ -6311,12 +6413,12 @@ class Router: 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 = [] - + if model_name is not None: # O(1) lookup in model_name index, then O(k) iteration where k = deployments for this model_name if model_name in self.model_name_to_deployment_indices: @@ -6337,7 +6439,7 @@ class Router: if exclude_team_models and model["model_info"].get("team_id"): continue ids.append(model_id) - + return ids def has_model_id(self, candidate_id: str) -> bool: @@ -6399,15 +6501,15 @@ class Router: 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. """ returned_models: List[DeploymentTypedDict] = [] - + # O(1) lookup in model_name index if model_name in self.model_name_to_deployment_indices: indices = self.model_name_to_deployment_indices[model_name] - + # O(k) where k = deployments for this model_name (typically 1-10) for idx in indices: model = self.model_list[idx] @@ -6556,9 +6658,7 @@ class Router: potential_team_only_wildcard_models = ( self.team_pattern_routers[team_id].route(model_name) or [] ) - potential_wildcard_models.extend( - potential_team_only_wildcard_models - ) + potential_wildcard_models.extend(potential_team_only_wildcard_models) if model_name is not None and potential_wildcard_models is not None: for m in potential_wildcard_models: @@ -6821,7 +6921,7 @@ class Router: # Cache nested dict access to avoid repeated temporary dict allocations _litellm_params = deployment.get("litellm_params", {}) _model_info = deployment.get("model_info", {}) - + # see if we have the info for this model try: base_model = _model_info.get("base_model", None) @@ -6949,7 +7049,9 @@ class Router: if len(invalid_model_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 invalid_model_indices + d + for i, d in enumerate(_returned_deployments) + if i not in invalid_model_indices ] ## ORDER FILTERING ## -> if user set 'order' in deployments, return deployments with lowest order (e.g. order=1 > order=2) @@ -7071,20 +7173,33 @@ class Router: ) if len(healthy_deployments) == 0: - if self.get_model_list(model_name=model) is None: - message = f"You passed in model={model}. There is no 'model_name' with this string".format( - model - ) - else: - message = f"You passed in model={model}. There are no healthy deployments for this model".format( - model - ) + # Check for default fallbacks if no deployments are found for the requested model + if self._has_default_fallbacks(): + fallback_model = self._get_first_default_fallback() + if fallback_model: + verbose_router_logger.info( + f"Model '{model}' not found. Attempting to use default fallback model '{fallback_model}'." + ) + # Re-assign model to the fallback and try to get deployments again + model = fallback_model + healthy_deployments = self._get_all_deployments(model_name=model) - raise litellm.BadRequestError( - message=message, - model=model, - llm_provider="", - ) + # If still no deployments after checking for fallbacks, raise an error + if len(healthy_deployments) == 0: + if self.get_model_list(model_name=model) is None: + message = f"You passed in model={model}. There is no 'model_name' with this string".format( + model + ) + else: + message = f"You passed in model={model}. There are no healthy deployments for this model".format( + model + ) + + raise litellm.BadRequestError( + message=message, + model=model, + llm_provider="", + ) if litellm.model_alias_map and model in litellm.model_alias_map: model = litellm.model_alias_map[ @@ -7505,7 +7620,8 @@ class Router: # Convert to set for O(1) lookup and use list comprehension for O(n) filtering cooldown_set = set(cooldown_deployments) return [ - deployment for deployment in healthy_deployments + deployment + for deployment in healthy_deployments if deployment["model_info"]["id"] not in cooldown_set ] diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 5f66b0b09b..8851264db0 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -1053,7 +1053,6 @@ async def test_acompletion_streaming_iterator(): "async_function_with_fallbacks_common_utils", return_value=mock_fallback_response, ) as mock_fallback_utils: - collected_chunks = [] result = await router._acompletion_streaming_iterator( model_response=mock_error_response, @@ -1150,7 +1149,6 @@ async def test_acompletion_streaming_iterator_edge_cases(): "async_function_with_fallbacks_common_utils", return_value=mock_fallback_response, ) as mock_fallback_utils: - collected_chunks = [] iterator = await router._acompletion_streaming_iterator( model_response=mock_response, @@ -1576,7 +1574,8 @@ def test_add_deployment_model_to_endpoint_for_llm_passthrough_route(): model_name="bedrock/us.anthropic.claude-3-5-sonnet-20240620-v1:0", ) assert ( - result["endpoint"] == "/model/us.anthropic.claude-3-5-sonnet-20240620-v1:0/invoke" + result["endpoint"] + == "/model/us.anthropic.claude-3-5-sonnet-20240620-v1:0/invoke" ), f"Expected '/model/us.anthropic.claude-3-5-sonnet-20240620-v1:0/invoke', got '{result['endpoint']}'" # Test Case 2: Bedrock invoke-with-response-stream endpoint @@ -1590,7 +1589,8 @@ def test_add_deployment_model_to_endpoint_for_llm_passthrough_route(): model_name="bedrock/us.anthropic.claude-3-5-sonnet-20240620-v1:0", ) assert ( - result["endpoint"] == "/model/us.anthropic.claude-3-5-sonnet-20240620-v1:0/invoke-with-response-stream" + result["endpoint"] + == "/model/us.anthropic.claude-3-5-sonnet-20240620-v1:0/invoke-with-response-stream" ), f"Expected streaming endpoint with stripped prefix, got '{result['endpoint']}'" # Test Case 3: Bedrock converse endpoint @@ -1619,3 +1619,76 @@ def test_add_deployment_model_to_endpoint_for_llm_passthrough_route(): assert ( result["endpoint"] == "/model/us.meta.llama3-8b-instruct-v1:0/invoke" ), f"Expected '/model/us.meta.llama3-8b-instruct-v1:0/invoke', got '{result['endpoint']}'" + + +@pytest.mark.asyncio +async def test_router_acompletion_with_unknown_model_and_default_fallback(): + """ + Test that the router successfully uses a default fallback when a completely + unknown model is requested. It should not raise a BadRequestError. + This test verifies the fix for issue #15114. + """ + model_list = [ + { + "model_name": "gpt-4o", # This is the fallback model + "litellm_params": { + "model": "azure/gpt-4o-real", # The actual underlying model name + "api_key": "fake-key", + "api_base": "https://fake-endpoint.openai.azure.com/", + "mock_response": "this is the fallback response", # Mocked response to prevent real API calls + }, + } + ] + + # Initialize the router with a default fallback + router = litellm.Router(model_list=model_list, default_fallbacks=["gpt-4o"]) + + messages = [ + {"role": "user", "content": "This call should succeed by falling back."} + ] + + # Call completion with a model name that is NOT in the model_list + response = await router.acompletion( + model="completely-unknown-model", messages=messages + ) + + # Check that the call did not fail and we received a valid response object. + assert response is not None + + # Check that the content of the response is from the MOCKED fallback model. + assert response.choices[0].message.content == "this is the fallback response" + + # Check that the response object reports the model that was *actually* called. + assert response.model == "gpt-4o-real" + + +@pytest.mark.asyncio +async def test_router_acompletion_with_unknown_model_and_no_fallback(): + """ + Test that the router still raises a BadRequestError for an unknown model + when no default fallbacks are configured. This ensures we don't break + the original behavior. + """ + model_list = [ + { + "model_name": "gpt-4o", + "litellm_params": { + "model": "azure/gpt-4o-real", + "api_key": "fake-key", + "mock_response": "this should not be called", + }, + } + ] + + # Initialize the router WITHOUT any default fallbacks + router = litellm.Router(model_list=model_list) + + messages = [{"role": "user", "content": "This call should fail."}] + + # Use pytest.raises to assert that a BadRequestError is thrown. + with pytest.raises(litellm.BadRequestError) as excinfo: + await router.acompletion(model="completely-unknown-model", messages=messages) + + # Check that the error message is correct. + # The router returns 'no healthy deployments' because get_model_list returns [] not None. + assert "no healthy deployments for this model" in str(excinfo.value)