From 3cb5ceb98cdebc6e8e9b08fd87a9c9c7bb1f947d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:27:34 +0000 Subject: [PATCH 1/3] fix(xai): honor nested web_search filters on the xAI Responses API Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/xai/responses/transformation.py | 18 +++----- .../test_xai_responses_transformation.py | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/litellm/llms/xai/responses/transformation.py b/litellm/llms/xai/responses/transformation.py index 646d6798783..007cecbe049 100644 --- a/litellm/llms/xai/responses/transformation.py +++ b/litellm/llms/xai/responses/transformation.py @@ -81,30 +81,24 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): - enable_image_understanding XAI does NOT support search_context_size (OpenAI-specific). + + Domains may come nested under 'filters' (the OpenAI/XAI documented shape) or flat on the tool. """ xai_tool: Final[dict[str, object]] = {"type": "web_search"} - # Remove search_context_size if present (not supported by XAI) if "search_context_size" in tool: verbose_logger.info( "XAI does not support 'search_context_size' parameter. Removing it from web_search tool." ) - # Handle filters (XAI-specific structure) - filters: Final = {} - if "allowed_domains" in tool: - allowed_domains: Final = tool["allowed_domains"] - filters["allowed_domains"] = allowed_domains + domains: Final = tool.get("filters") or tool + filters: Final = { + key: domains[key] for key in ("allowed_domains", "excluded_domains") if key in domains + } - if "excluded_domains" in tool: - excluded_domains: Final = tool["excluded_domains"] - filters["excluded_domains"] = excluded_domains - - # Add filters if any were specified if filters: xai_tool["filters"] = filters - # Handle enable_image_understanding (top-level in XAI format) if "enable_image_understanding" in tool: xai_tool["enable_image_understanding"] = tool["enable_image_understanding"] diff --git a/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py b/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py index 4cff5c76b9e..be688e78bda 100644 --- a/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py +++ b/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py @@ -119,6 +119,51 @@ class TestXAIResponsesAPITransformation: assert tool["filters"]["allowed_domains"] == ["wikipedia.org", "x.ai"] assert tool["enable_image_understanding"] is True + def test_web_search_nested_filters_preserved(self): + """The documented nested 'filters' shape must reach xAI instead of being dropped""" + config = XAIResponsesAPIConfig() + + params = ResponsesAPIOptionalRequestParams( + tools=[ + { + "type": "web_search", + "filters": {"allowed_domains": ["grokipedia.com"], "excluded_domains": ["example.com"]}, + } + ] + ) + + result = config.map_openai_params( + response_api_optional_params=params, + model="grok-4-1-fast", + drop_params=False, + ) + + tool = result["tools"][0] + assert tool["filters"]["allowed_domains"] == ["grokipedia.com"] + assert tool["filters"]["excluded_domains"] == ["example.com"] + + def test_web_search_nested_filters_win_over_flat(self): + """Nested filters take precedence when both shapes are sent""" + config = XAIResponsesAPIConfig() + + params = ResponsesAPIOptionalRequestParams( + tools=[ + { + "type": "web_search", + "allowed_domains": ["flat.com"], + "filters": {"allowed_domains": ["nested.com"]}, + } + ] + ) + + result = config.map_openai_params( + response_api_optional_params=params, + model="grok-4-1-fast", + drop_params=False, + ) + + assert result["tools"][0]["filters"] == {"allowed_domains": ["nested.com"]} + def test_web_search_search_context_size_removed(self): """Test that search_context_size is removed from web_search tools""" config = XAIResponsesAPIConfig() From d415c2856f143d6f9d038c601d1e8f22cbf9705d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:29:49 +0000 Subject: [PATCH 2/3] style: ruff format Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/xai/responses/transformation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/llms/xai/responses/transformation.py b/litellm/llms/xai/responses/transformation.py index 007cecbe049..291d7a5f690 100644 --- a/litellm/llms/xai/responses/transformation.py +++ b/litellm/llms/xai/responses/transformation.py @@ -92,9 +92,7 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): ) domains: Final = tool.get("filters") or tool - filters: Final = { - key: domains[key] for key in ("allowed_domains", "excluded_domains") if key in domains - } + filters: Final = {key: domains[key] for key in ("allowed_domains", "excluded_domains") if key in domains} if filters: xai_tool["filters"] = filters From afb6f8be6538100d2aefc89501277025646796b4 Mon Sep 17 00:00:00 2001 From: yassin Date: Tue, 15 Sep 2026 21:24:32 +0000 Subject: [PATCH 3/3] fix(xai): treat an explicit empty web_search filters object as unrestricted Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/xai/responses/transformation.py | 8 +++++++- .../test_xai_responses_transformation.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/litellm/llms/xai/responses/transformation.py b/litellm/llms/xai/responses/transformation.py index 291d7a5f690..1f977a66186 100644 --- a/litellm/llms/xai/responses/transformation.py +++ b/litellm/llms/xai/responses/transformation.py @@ -3,6 +3,7 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final import httpx +from pydantic import TypeAdapter import litellm from litellm._logging import verbose_logger @@ -32,6 +33,8 @@ if TYPE_CHECKING: else: LiteLLMLoggingObj = Any +_STR_MAPPING_ADAPTER: Final = TypeAdapter(Mapping[str, object]) + def _usage_restated_from_xai_ticks(usage: ResponseAPIUsage | None) -> ResponseAPIUsage | None: reported_cost: Final = xai_reported_cost_in_usd(getattr(usage, "cost_in_usd_ticks", None)) @@ -91,7 +94,10 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): "XAI does not support 'search_context_size' parameter. Removing it from web_search tool." ) - domains: Final = tool.get("filters") or tool + nested_filters: Final = tool.get("filters") + domains: Final = ( + _STR_MAPPING_ADAPTER.validate_python(nested_filters) if isinstance(nested_filters, Mapping) else tool + ) filters: Final = {key: domains[key] for key in ("allowed_domains", "excluded_domains") if key in domains} if filters: diff --git a/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py b/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py index be688e78bda..8f933f7e5c2 100644 --- a/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py +++ b/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py @@ -164,6 +164,22 @@ class TestXAIResponsesAPITransformation: assert result["tools"][0]["filters"] == {"allowed_domains": ["nested.com"]} + def test_web_search_empty_nested_filters_win_over_flat(self): + """An explicit empty 'filters' object means unrestricted search, even when stale flat fields are present""" + config = XAIResponsesAPIConfig() + + params = ResponsesAPIOptionalRequestParams( + tools=[{"type": "web_search", "allowed_domains": ["flat.com"], "filters": {}}] + ) + + result = config.map_openai_params( + response_api_optional_params=params, + model="grok-4-1-fast", + drop_params=False, + ) + + assert result["tools"][0] == {"type": "web_search"} + def test_web_search_search_context_size_removed(self): """Test that search_context_size is removed from web_search tools""" config = XAIResponsesAPIConfig()