From 72eb4c3a1c6b311688d8772ceb3036840eef540c Mon Sep 17 00:00:00 2001 From: Raghav Jhavar <156360524+raghav-stripe@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:18:20 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=95=20=20feat:=20support=20routing=20t?= =?UTF-8?q?o=20only=20websearch=20supported=20deployments=20(#17500)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * support routing to only websearch supported deployments * add docs --- docs/my-website/docs/completion/web_search.md | 16 ++ litellm/router.py | 14 +- litellm/router_utils/common_utils.py | 53 +++++++ .../test_router_utils_common_utils.py | 150 +++++++++++++++++- 4 files changed, 231 insertions(+), 2 deletions(-) diff --git a/docs/my-website/docs/completion/web_search.md b/docs/my-website/docs/completion/web_search.md index b0d8fcdf4c0..db50c7b5bc5 100644 --- a/docs/my-website/docs/completion/web_search.md +++ b/docs/my-website/docs/completion/web_search.md @@ -371,6 +371,22 @@ model_list: web_search_options: {} # Enables web search with default settings ``` +### Advanced +You can configure LiteLLM's router to optionally drop models that do not support WebSearch, for example +```yaml + - model_name: gpt-4.1 + litellm_params: + model: openai/gpt-4.1 + - model_name: gpt-4.1 + litellm_params: + model: azure/gpt-4.1 + api_base: "x.openai.azure.com/" + api_version: 2025-03-01-preview + model_info: + supports_web_search: False <---- KEY CHANGE! +``` +In this example, LiteLLM will still route LLM requests to both deployments, but for WebSearch, will solely route to OpenAI. + diff --git a/litellm/router.py b/litellm/router.py index 9488f341cbf..efc622ed3c3 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -43,6 +43,10 @@ import litellm import litellm.litellm_core_utils import litellm.litellm_core_utils.exception_mapping_utils from litellm import get_secret_str +from litellm.router_utils.common_utils import ( + filter_team_based_models, + filter_web_search_deployments, +) from litellm._logging import verbose_router_logger from litellm._uuid import uuid from litellm.caching.caching import ( @@ -7445,7 +7449,6 @@ class Router: *OR* - Dict, if specific model chosen """ - from litellm.router_utils.common_utils import filter_team_based_models model, healthy_deployments = self._common_checks_available_deployment( model=model, @@ -7462,6 +7465,15 @@ class Router: request_kwargs=request_kwargs, ) + verbose_router_logger.debug(f"healthy_deployments after team filter: {healthy_deployments}") + + healthy_deployments = filter_web_search_deployments( + healthy_deployments=healthy_deployments, + request_kwargs=request_kwargs, + ) + + verbose_router_logger.debug(f"healthy_deployments after web search filter: {healthy_deployments}") + if isinstance(healthy_deployments, dict): return healthy_deployments diff --git a/litellm/router_utils/common_utils.py b/litellm/router_utils/common_utils.py index 18658d951dc..15725e30d04 100644 --- a/litellm/router_utils/common_utils.py +++ b/litellm/router_utils/common_utils.py @@ -6,6 +6,7 @@ if TYPE_CHECKING: from litellm.types.llms.openai import OpenAIFileObject from litellm.types.router import CredentialLiteLLMParams +from litellm._logging import verbose_logger def get_litellm_params_sensitive_credential_hash(litellm_params: dict) -> str: @@ -73,3 +74,55 @@ def filter_team_based_models( for deployment in healthy_deployments if deployment.get("model_info", {}).get("id") not in ids_to_remove ] + +def _deployment_supports_web_search(deployment: Dict) -> bool: + """ + Check if a deployment supports web search. + + Priority: + 1. Check config-level override in model_info.supports_web_search + 2. Default to True (assume supported unless explicitly disabled) + + Note: Ideally we'd fall back to litellm.supports_web_search() but + model_prices_and_context_window.json doesn't have supports_web_search + tags on all models yet. TODO: backfill and add fallback. + """ + model_info = deployment.get("model_info", {}) + + if "supports_web_search" in model_info: + return model_info["supports_web_search"] + + return True + + +def filter_web_search_deployments( + healthy_deployments: Union[List[Dict], Dict], + request_kwargs: Optional[Dict] = None, +) -> Union[List[Dict], Dict]: + """ + If the request is websearch, filter out deployments that don't support web search + """ + if request_kwargs is None: + return healthy_deployments + # When a specific deployment was already chosen, it's returned as a dict + # rather than a list - nothing to filter, just pass through + if isinstance(healthy_deployments, dict): + return healthy_deployments + + is_web_search_request = False + tools = request_kwargs.get("tools", []) + for tool in tools: + # These are the two websearch tools for OpenAI / Azure. + if tool.get("type") == "web_search" or tool.get("type") == "web_search_preview": + is_web_search_request = True + break + + if not is_web_search_request: + return healthy_deployments + + # Filter out deployments that don't support web search + final_deployments = [d for d in healthy_deployments if _deployment_supports_web_search(d)] + if len(healthy_deployments) > 0 and len(final_deployments) == 0: + verbose_logger.warning("No deployments support web search for request") + return final_deployments + diff --git a/tests/test_litellm/router_utils/test_router_utils_common_utils.py b/tests/test_litellm/router_utils/test_router_utils_common_utils.py index 79b975a8786..f73609d47ba 100644 --- a/tests/test_litellm/router_utils/test_router_utils_common_utils.py +++ b/tests/test_litellm/router_utils/test_router_utils_common_utils.py @@ -3,7 +3,11 @@ from unittest.mock import Mock import pytest -from litellm.router_utils.common_utils import filter_team_based_models +from litellm.router_utils.common_utils import ( + _deployment_supports_web_search, + filter_team_based_models, + filter_web_search_deployments, +) class TestFilterTeamBasedModels: @@ -187,3 +191,147 @@ class TestFilterTeamBasedModels: expected_ids = ["deployment-1", "deployment-2"] result_ids = [d.get("model_info", {}).get("id") for d in result] assert sorted(result_ids) == sorted(expected_ids) + + +class TestDeploymentSupportsWebSearch: + """Test cases for _deployment_supports_web_search helper function""" + + def test_model_info_true(self): + """model_info.supports_web_search=True returns True""" + deployment = {"model_info": {"supports_web_search": True}} + assert _deployment_supports_web_search(deployment) is True + + def test_model_info_false(self): + """model_info.supports_web_search=False returns False""" + deployment = {"model_info": {"supports_web_search": False}} + assert _deployment_supports_web_search(deployment) is False + + def test_no_config_defaults_to_true(self): + """When no supports_web_search in config, default to True""" + deployment = {"litellm_params": {"model": "gpt-4"}, "model_info": {"id": "123"}} + assert _deployment_supports_web_search(deployment) is True + + def test_empty_deployment_defaults_to_true(self): + """Empty deployment defaults to True""" + assert _deployment_supports_web_search({}) is True + + def test_missing_model_info_defaults_to_true(self): + """When model_info missing, default to True""" + deployment = {"litellm_params": {"model": "gpt-4"}} + assert _deployment_supports_web_search(deployment) is True + + +class TestFilterWebSearchDeployments: + """Test cases for filter_web_search_deployments function""" + + @pytest.fixture + def sample_deployments(self) -> List[Dict]: + """Sample deployments with varying web search support""" + return [ + {"model_info": {"id": "deployment-1"}}, # default True + {"model_info": {"id": "deployment-2", "supports_web_search": True}}, + {"model_info": {"id": "deployment-3", "supports_web_search": False}}, + ] + + def test_no_request_kwargs_returns_all(self, sample_deployments): + """When request_kwargs is None, return all deployments""" + result = filter_web_search_deployments(sample_deployments, None) + assert result == sample_deployments + + def test_no_tools_returns_all(self, sample_deployments): + """When no tools in request, return all deployments""" + result = filter_web_search_deployments(sample_deployments, {"other": "value"}) + assert result == sample_deployments + + def test_empty_tools_returns_all(self, sample_deployments): + """When tools list is empty, return all deployments""" + result = filter_web_search_deployments(sample_deployments, {"tools": []}) + assert result == sample_deployments + + def test_non_web_search_tools_returns_all(self, sample_deployments): + """When tools don't include web_search, return all deployments""" + request_kwargs = {"tools": [{"type": "function", "function": {}}]} + result = filter_web_search_deployments(sample_deployments, request_kwargs) + assert result == sample_deployments + + def test_web_search_filters_unsupported(self, sample_deployments): + """When web_search tool present, filter out deployments that don't support it""" + request_kwargs = {"tools": [{"type": "web_search"}]} + result = filter_web_search_deployments(sample_deployments, request_kwargs) + # Should exclude deployment-3 (supports_web_search=False) + assert len(result) == 2 + result_ids = [d["model_info"]["id"] for d in result] + assert "deployment-1" in result_ids + assert "deployment-2" in result_ids + assert "deployment-3" not in result_ids + + def test_web_search_preview_filters_unsupported(self, sample_deployments): + """web_search_preview type should also trigger filtering""" + request_kwargs = {"tools": [{"type": "web_search_preview"}]} + result = filter_web_search_deployments(sample_deployments, request_kwargs) + assert len(result) == 2 + result_ids = [d["model_info"]["id"] for d in result] + assert "deployment-3" not in result_ids + + def test_web_search_with_other_tools(self, sample_deployments): + """Web search filtering works when mixed with other tools""" + request_kwargs = { + "tools": [ + {"type": "function", "function": {"name": "get_weather"}}, + {"type": "web_search"}, + ] + } + result = filter_web_search_deployments(sample_deployments, request_kwargs) + assert len(result) == 2 + result_ids = [d["model_info"]["id"] for d in result] + assert "deployment-3" not in result_ids + + def test_all_deployments_support_web_search(self): + """When all deployments support web search, none are filtered""" + deployments = [ + {"model_info": {"id": "d1", "supports_web_search": True}}, + {"model_info": {"id": "d2", "supports_web_search": True}}, + ] + request_kwargs = {"tools": [{"type": "web_search"}]} + result = filter_web_search_deployments(deployments, request_kwargs) + assert len(result) == 2 + + def test_no_deployments_support_web_search(self): + """When no deployments support web search, all are filtered out""" + deployments = [ + {"model_info": {"id": "d1", "supports_web_search": False}}, + {"model_info": {"id": "d2", "supports_web_search": False}}, + ] + request_kwargs = {"tools": [{"type": "web_search"}]} + result = filter_web_search_deployments(deployments, request_kwargs) + assert len(result) == 0 + + def test_missing_config_defaults_to_supported(self): + """Deployments without supports_web_search config default to True""" + deployments = [ + {"model_info": {"id": "d1"}}, # No supports_web_search - defaults to True + {"model_info": {"id": "d2"}}, # No supports_web_search - defaults to True + {"model_info": {"id": "d3", "supports_web_search": False}}, # Explicit False + ] + request_kwargs = {"tools": [{"type": "web_search"}]} + result = filter_web_search_deployments(deployments, request_kwargs) + # d1 and d2 should be included (default True), d3 excluded (explicit False) + assert len(result) == 2 + result_ids = [d["model_info"]["id"] for d in result] + assert "d1" in result_ids + assert "d2" in result_ids + assert "d3" not in result_ids + + def test_empty_deployments_list(self): + """Empty deployments list returns empty list""" + request_kwargs = {"tools": [{"type": "web_search"}]} + result = filter_web_search_deployments([], request_kwargs) + assert result == [] + + def test_dict_deployment_passthrough(self): + """When deployment is a dict (single deployment), pass through unchanged""" + deployment = {"model_info": {"id": "d1", "supports_web_search": False}} + request_kwargs = {"tools": [{"type": "web_search"}]} + result = filter_web_search_deployments(deployment, request_kwargs) + # Should return the dict unchanged, not filter it + assert result == deployment