From 3438050eeae33e1ae2237add6c582f26507b500a Mon Sep 17 00:00:00 2001 From: linhongyu510 Date: Tue, 25 Aug 2026 18:12:20 +0800 Subject: [PATCH 1/5] fix(search): forward provider-specific litellm_params from search_tools The search router forwarded only search_provider/api_key/api_base from a search_tools entry's litellm_params, silently dropping every other yaml key (e.g. tool_name for the agentcore provider) with no warning or error (see call, mirroring how model_list litellm_params flow into completion calls; credentials stay resolved explicitly and request-body params win over yaml on key collisions. Adds provider-free regression tests covering forwarding, request-body override precedence, no duplicate-credential kwargs, and the preserved missing-search_provider guard. --- litellm/router_utils/search_api_router.py | 7 +- .../router_utils/test_search_api_router.py | 141 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/router_utils/test_search_api_router.py diff --git a/litellm/router_utils/search_api_router.py b/litellm/router_utils/search_api_router.py index ab5ef5853c9..a61b2e6f35b 100644 --- a/litellm/router_utils/search_api_router.py +++ b/litellm/router_utils/search_api_router.py @@ -227,7 +227,12 @@ class SearchAPIRouter: verbose_router_logger.debug("Selected search tool with provider: %s", search_provider) - # Call the original search function with the provider config + # Call the original search function with the provider config. + # Provider-specific yaml `litellm_params` (e.g. agentcore `tool_name`) + # and request-body params are forwarded via `search_params` (see + # #37538); credential keys are separated from the logged/transformed + # optional params downstream in `litellm.search` and are only exposed + # to request signing. response: Final = await original_generic_function( search_provider=search_provider, api_key=api_key, diff --git a/tests/test_litellm/router_utils/test_search_api_router.py b/tests/test_litellm/router_utils/test_search_api_router.py new file mode 100644 index 00000000000..914b3b576bc --- /dev/null +++ b/tests/test_litellm/router_utils/test_search_api_router.py @@ -0,0 +1,141 @@ +"""Regression tests for `SearchAPIRouter.async_search_with_fallbacks_helper`. + +Covers #37538: provider-specific keys in a `search_tools` entry's +`litellm_params` (e.g. `tool_name` for the agentcore provider) used to be +silently dropped, because the helper forwarded only `search_provider`, +`api_key`, and `api_base` to the underlying search call. Every other yaml key +was ignored with no warning. These tests are provider-free: they capture the +kwargs handed to the search function via a fake `original_generic_function`. +""" + +from typing import Any + +import pytest + +from litellm.router_utils.search_api_router import SearchAPIRouter + + +class _FakeRouter: + """Minimal stand-in exposing only the `search_tools` the helper reads.""" + + def __init__(self, search_tools: list[dict[str, Any]]): + self.search_tools = search_tools + + +def _make_router(litellm_params: dict[str, Any], name: str = "agentcore-search") -> _FakeRouter: + return _FakeRouter( + [{"search_tool_name": name, "litellm_params": {**litellm_params}}] + ) + + +@pytest.mark.asyncio +async def test_provider_specific_litellm_params_are_forwarded(): + """A yaml `tool_name` (and any other non-credential key) must reach the call.""" + captured: dict[str, Any] = {} + + async def fake_search(**kwargs: Any): + captured.update(kwargs) + return {"object": "search", "results": []} + + router = _make_router( + { + "search_provider": "agentcore", + "api_base": "https://gateway.example/mcp", + "tool_name": "MyTarget___WebSearch", + } + ) + + await SearchAPIRouter.async_search_with_fallbacks_helper( + router_instance=router, + model="agentcore-search", + original_generic_function=fake_search, + query="anything", + max_results=2, + ) + + assert captured["search_provider"] == "agentcore" + assert captured["api_base"] == "https://gateway.example/mcp" + # The previously-dropped key now reaches the provider. + assert captured["tool_name"] == "MyTarget___WebSearch" + # And the request-body params are still forwarded untouched. + assert captured["query"] == "anything" + assert captured["max_results"] == 2 + + +@pytest.mark.asyncio +async def test_request_body_params_win_over_yaml_on_collision(): + """Per-request kwargs must override colliding yaml `litellm_params` keys.""" + captured: dict[str, Any] = {} + + async def fake_search(**kwargs: Any): + captured.update(kwargs) + return {"object": "search", "results": []} + + router = _make_router( + { + "search_provider": "agentcore", + "tool_name": "yaml___WebSearch", + } + ) + + await SearchAPIRouter.async_search_with_fallbacks_helper( + router_instance=router, + model="agentcore-search", + original_generic_function=fake_search, + query="anything", + tool_name="request___WebSearch", # collides with yaml + ) + + assert captured["tool_name"] == "request___WebSearch" + + +@pytest.mark.asyncio +async def test_credentials_are_not_double_passed(): + """`api_key`/`api_base` are resolved explicitly and must not also leak in as + duplicate kwargs (which would raise `TypeError: multiple values`).""" + captured: dict[str, Any] = {} + + async def fake_search(*, search_provider, api_key, api_base, **kwargs: Any): + captured.update( + {"search_provider": search_provider, "api_key": api_key, "api_base": api_base, **kwargs} + ) + return {"object": "search", "results": []} + + router = _make_router( + { + "search_provider": "agentcore", + "api_key": "sk-secret", + "api_base": "https://gateway.example/mcp", + "tool_name": "MyTarget___WebSearch", + } + ) + + # Must not raise TypeError for duplicate api_key/api_base. + await SearchAPIRouter.async_search_with_fallbacks_helper( + router_instance=router, + model="agentcore-search", + original_generic_function=fake_search, + query="anything", + ) + + assert captured["api_key"] == "sk-secret" + assert captured["api_base"] == "https://gateway.example/mcp" + assert captured["tool_name"] == "MyTarget___WebSearch" + + +@pytest.mark.asyncio +async def test_missing_search_provider_still_raises(): + """The existing guard for a missing `search_provider` must be preserved.""" + + async def fake_search(**kwargs: Any): # pragma: no cover - should not be called + raise AssertionError("search should not be invoked") + + router = _make_router({"tool_name": "MyTarget___WebSearch"}) + + with pytest.raises(ValueError, match="search_provider not found"): + await SearchAPIRouter.async_search_with_fallbacks_helper( + router_instance=router, + model="agentcore-search", + original_generic_function=fake_search, + query="anything", + ) From 6483b71b3c466cd9f26691299b3b37672cbc2425 Mon Sep 17 00:00:00 2001 From: linhongyu510 Date: Sat, 29 Aug 2026 14:20:38 +0800 Subject: [PATCH 2/5] fix(search): keep provider credentials out of logs --- litellm/llms/custom_httpx/llm_http_handler.py | 7 +- litellm/search/main.py | 12 +- .../router_utils/test_search_api_router.py | 105 +++++++++++++++++- 3 files changed, 115 insertions(+), 9 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 834f7d564a2..19f0e212902 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1777,6 +1777,7 @@ class BaseLLMHTTPHandler: asearch: bool = False, headers: dict[str, object] | None = None, provider_config: BaseSearchConfig | None = None, + auth_params: dict[str, object] | None = None, ) -> SearchResponse | Coroutine[object, object, SearchResponse]: """ Sync Search handler. @@ -1796,6 +1797,7 @@ class BaseLLMHTTPHandler: client=client, headers=headers, provider_config=provider_config, + auth_params=auth_params, ) # Validate environment and get headers @@ -1821,7 +1823,7 @@ class BaseLLMHTTPHandler: signed_headers, signed_json_body = provider_config.sign_request( headers=headers, - optional_params=optional_params, + optional_params={**optional_params, **(auth_params or {})}, request_data=data, api_base=complete_url, api_key=api_key, @@ -1882,6 +1884,7 @@ class BaseLLMHTTPHandler: client: HTTPHandler | AsyncHTTPHandler | None = None, headers: dict[str, object] | None = None, provider_config: BaseSearchConfig | None = None, + auth_params: dict[str, object] | None = None, ) -> SearchResponse: """ Async Search handler. @@ -1915,7 +1918,7 @@ class BaseLLMHTTPHandler: signed_headers, signed_json_body = provider_config.sign_request( headers=headers, - optional_params=optional_params, + optional_params={**optional_params, **(auth_params or {})}, request_data=data, api_base=complete_url, api_key=api_key, diff --git a/litellm/search/main.py b/litellm/search/main.py index b2dd51799a1..29addce1094 100644 --- a/litellm/search/main.py +++ b/litellm/search/main.py @@ -6,7 +6,7 @@ import asyncio import contextvars from collections.abc import Coroutine from functools import partial -from typing import Any, Final +from typing import Any, Final, cast import httpx @@ -249,6 +249,15 @@ def search( verbose_logger.debug("Search call - provider: %s", search_provider) + auth_param_names: Final[frozenset[str]] = frozenset( + cast( # cast-ok: BaseAWSLLM exposes authentication parameter names as list[str] + list[str], getattr(search_provider_config, "aws_authentication_params", ()) + ) + ) + auth_params: Final[dict[str, object]] = { # mutable-ok: isolated provider signing params + key: kwargs.pop(key) for key in tuple(kwargs) if key in auth_param_names + } + # Build optional_params from explicit parameters optional_params: Final = _build_search_optional_params( max_results=max_results, @@ -306,6 +315,7 @@ def search( asearch=_is_async, headers=headers, provider_config=search_provider_config, + auth_params=auth_params, ) return response diff --git a/tests/test_litellm/router_utils/test_search_api_router.py b/tests/test_litellm/router_utils/test_search_api_router.py index 914b3b576bc..a817ab8472d 100644 --- a/tests/test_litellm/router_utils/test_search_api_router.py +++ b/tests/test_litellm/router_utils/test_search_api_router.py @@ -8,12 +8,22 @@ was ignored with no warning. These tests are provider-free: they capture the kwargs handed to the search function via a fake `original_generic_function`. """ +import importlib from typing import Any +from unittest.mock import MagicMock import pytest +from litellm.llms.base_llm.search.transformation import ( + BaseSearchConfig, + SearchResponse, +) +from litellm.llms.bedrock.search.transformation import AgentCoreSearchConfig +from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler from litellm.router_utils.search_api_router import SearchAPIRouter +search_module = importlib.import_module("litellm.search.main") + class _FakeRouter: """Minimal stand-in exposing only the `search_tools` the helper reads.""" @@ -23,9 +33,7 @@ class _FakeRouter: def _make_router(litellm_params: dict[str, Any], name: str = "agentcore-search") -> _FakeRouter: - return _FakeRouter( - [{"search_tool_name": name, "litellm_params": {**litellm_params}}] - ) + return _FakeRouter([{"search_tool_name": name, "litellm_params": {**litellm_params}}]) @pytest.mark.asyncio @@ -96,9 +104,7 @@ async def test_credentials_are_not_double_passed(): captured: dict[str, Any] = {} async def fake_search(*, search_provider, api_key, api_base, **kwargs: Any): - captured.update( - {"search_provider": search_provider, "api_key": api_key, "api_base": api_base, **kwargs} - ) + captured.update({"search_provider": search_provider, "api_key": api_key, "api_base": api_base, **kwargs}) return {"object": "search", "results": []} router = _make_router( @@ -139,3 +145,90 @@ async def test_missing_search_provider_still_raises(): original_generic_function=fake_search, query="anything", ) + + +def test_agentcore_auth_params_are_excluded_from_logging(monkeypatch: pytest.MonkeyPatch): + config = AgentCoreSearchConfig() + monkeypatch.setattr( + search_module.ProviderConfigManager, + "get_provider_search_config", + lambda provider: config, + ) + monkeypatch.setattr(config, "validate_environment", lambda **kwargs: {}) + monkeypatch.setattr( + config, + "get_complete_url", + lambda **kwargs: "https://gateway.example/mcp", + ) + + captured: dict[str, Any] = {} + + def fake_search(**kwargs: Any): + captured.update(kwargs) + return SearchResponse(results=[]) + + monkeypatch.setattr(search_module.base_llm_http_handler, "search", fake_search) + logging_obj = MagicMock() + + search_module.search.__wrapped__( + query="test", + search_provider="agentcore", + litellm_logging_obj=logging_obj, + tool_name="target___WebSearch", + aws_access_key_id="access", + aws_secret_access_key="secret", + aws_session_token="token", + aws_region_name="us-east-1", + ) + + assert captured["optional_params"] == {"tool_name": "target___WebSearch"} + assert captured["auth_params"] == { + "aws_access_key_id": "access", + "aws_secret_access_key": "secret", + "aws_session_token": "token", + "aws_region_name": "us-east-1", + } + logged = logging_obj.update_from_kwargs.call_args.kwargs + assert logged["kwargs"] == {"tool_name": "target___WebSearch"} + assert logged["optional_params"] == {"tool_name": "target___WebSearch"} + + +def test_search_handler_only_exposes_auth_params_to_signing(): + class CapturingConfig(BaseSearchConfig): + def __init__(self): + self.transformed_params: dict[str, object] | None = None + self.signing_params: dict[str, object] | None = None + + def validate_environment(self, **kwargs): + return {} + + def transform_search_request(self, query, optional_params, **kwargs): + self.transformed_params = optional_params + return {} + + def get_complete_url(self, **kwargs): + return "https://gateway.example/mcp" + + def sign_request(self, *, optional_params, **kwargs): + self.signing_params = optional_params + raise RuntimeError("stop before network") + + config = CapturingConfig() + with pytest.raises(RuntimeError, match="stop before network"): + BaseLLMHTTPHandler().search( + query="test", + optional_params={"tool_name": "target___WebSearch"}, + auth_params={"aws_secret_access_key": "secret"}, + timeout=1, + logging_obj=MagicMock(), + api_key=None, + api_base="https://gateway.example/mcp", + custom_llm_provider="agentcore", + provider_config=config, + ) + + assert config.transformed_params == {"tool_name": "target___WebSearch"} + assert config.signing_params == { + "tool_name": "target___WebSearch", + "aws_secret_access_key": "secret", + } From bbb15421b6fb612b0b1c8195ad69fb5917309f86 Mon Sep 17 00:00:00 2001 From: linhongyu510 Date: Sat, 29 Aug 2026 14:30:59 +0800 Subject: [PATCH 3/5] fix(search): avoid prohibited typing cast --- litellm/search/main.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/litellm/search/main.py b/litellm/search/main.py index 29addce1094..983bd4b3733 100644 --- a/litellm/search/main.py +++ b/litellm/search/main.py @@ -6,7 +6,7 @@ import asyncio import contextvars from collections.abc import Coroutine from functools import partial -from typing import Any, Final, cast +from typing import Any, Final import httpx @@ -250,9 +250,7 @@ def search( verbose_logger.debug("Search call - provider: %s", search_provider) auth_param_names: Final[frozenset[str]] = frozenset( - cast( # cast-ok: BaseAWSLLM exposes authentication parameter names as list[str] - list[str], getattr(search_provider_config, "aws_authentication_params", ()) - ) + name for name in getattr(search_provider_config, "aws_authentication_params", ()) if isinstance(name, str) ) auth_params: Final[dict[str, object]] = { # mutable-ok: isolated provider signing params key: kwargs.pop(key) for key in tuple(kwargs) if key in auth_param_names From 2b5eac8dfdba5c3458e805667687e8b736a92bdf Mon Sep 17 00:00:00 2001 From: linhongyu510 Date: Thu, 3 Sep 2026 12:13:38 +0800 Subject: [PATCH 4/5] fix(search): satisfy type discipline for auth params --- litellm/llms/custom_httpx/llm_http_handler.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 19f0e212902..8158ac66880 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1777,7 +1777,7 @@ class BaseLLMHTTPHandler: asearch: bool = False, headers: dict[str, object] | None = None, provider_config: BaseSearchConfig | None = None, - auth_params: dict[str, object] | None = None, + auth_params: dict[str, object] | None = None, # mutable-ok: search signing hooks require a mutable params dict ) -> SearchResponse | Coroutine[object, object, SearchResponse]: """ Sync Search handler. @@ -1823,7 +1823,10 @@ class BaseLLMHTTPHandler: signed_headers, signed_json_body = provider_config.sign_request( headers=headers, - optional_params={**optional_params, **(auth_params or {})}, + optional_params={ # mutable-ok: sign_request requires one merged dict + **optional_params, + **(auth_params or {}), # mutable-ok: absent auth params contribute no entries + }, request_data=data, api_base=complete_url, api_key=api_key, @@ -1884,7 +1887,7 @@ class BaseLLMHTTPHandler: client: HTTPHandler | AsyncHTTPHandler | None = None, headers: dict[str, object] | None = None, provider_config: BaseSearchConfig | None = None, - auth_params: dict[str, object] | None = None, + auth_params: dict[str, object] | None = None, # mutable-ok: search signing hooks require a mutable params dict ) -> SearchResponse: """ Async Search handler. @@ -1918,7 +1921,10 @@ class BaseLLMHTTPHandler: signed_headers, signed_json_body = provider_config.sign_request( headers=headers, - optional_params={**optional_params, **(auth_params or {})}, + optional_params={ # mutable-ok: sign_request requires one merged dict + **optional_params, + **(auth_params or {}), # mutable-ok: absent auth params contribute no entries + }, request_data=data, api_base=complete_url, api_key=api_key, From e68a6390d982aed0d7296e6726bb0262ac28bfbd Mon Sep 17 00:00:00 2001 From: linhongyu510 Date: Thu, 3 Sep 2026 12:39:38 +0800 Subject: [PATCH 5/5] test(search): cover async signing credential isolation --- .../router_utils/test_search_api_router.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/test_litellm/router_utils/test_search_api_router.py b/tests/test_litellm/router_utils/test_search_api_router.py index a817ab8472d..194050bcb27 100644 --- a/tests/test_litellm/router_utils/test_search_api_router.py +++ b/tests/test_litellm/router_utils/test_search_api_router.py @@ -232,3 +232,45 @@ def test_search_handler_only_exposes_auth_params_to_signing(): "tool_name": "target___WebSearch", "aws_secret_access_key": "secret", } + + +@pytest.mark.asyncio +async def test_async_search_handler_only_exposes_auth_params_to_signing(): + class CapturingConfig(BaseSearchConfig): + def __init__(self): + self.transformed_params: dict[str, object] | None = None + self.signing_params: dict[str, object] | None = None + + def validate_environment(self, **kwargs): + return {} + + def transform_search_request(self, query, optional_params, **kwargs): + self.transformed_params = optional_params + return {} + + def get_complete_url(self, **kwargs): + return "https://gateway.example/mcp" + + def sign_request(self, *, optional_params, **kwargs): + self.signing_params = optional_params + raise RuntimeError("stop before network") + + config = CapturingConfig() + with pytest.raises(RuntimeError, match="stop before network"): + await BaseLLMHTTPHandler().async_search( + query="test", + optional_params={"tool_name": "target___WebSearch"}, + auth_params={"aws_secret_access_key": "secret"}, + timeout=1, + logging_obj=MagicMock(), + api_key=None, + api_base="https://gateway.example/mcp", + custom_llm_provider="agentcore", + provider_config=config, + ) + + assert config.transformed_params == {"tool_name": "target___WebSearch"} + assert config.signing_params == { + "tool_name": "target___WebSearch", + "aws_secret_access_key": "secret", + }