From cb057ad44bced6d530bd6092ca3429f046cbb8dc Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 9 Apr 2026 18:48:38 +0530 Subject: [PATCH 01/19] fix(websearch_interception): ensure spend/cost logging runs when stream=True MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployment hook now converts stream=True→False in wrapper_async's scope so the streaming early-return path is skipped and logging executes. logging_obj.stream is synced after the hook, and the original stream intent is recovered for the short-circuit path. Made-with: Cursor --- .../websearch_interception/handler.py | 12 +++-- .../messages/handler.py | 8 ++-- litellm/utils.py | 5 ++ .../test_websearch_interception_handler.py | 48 ++++++++++++++++++- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 2e5a873408..30fd55a3e9 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -230,8 +230,15 @@ class WebSearchInterceptionLogger(CustomLogger): # Keep other tools as-is converted_tools.append(tool) - # Update tools in-place and return full kwargs kwargs["tools"] = converted_tools + + if kwargs.get("stream"): + verbose_logger.debug( + "WebSearchInterception: deployment hook converting stream=True to stream=False" + ) + kwargs["stream"] = False + kwargs["_websearch_interception_converted_stream"] = True + return kwargs @classmethod @@ -344,13 +351,12 @@ class WebSearchInterceptionLogger(CustomLogger): else: converted_tools.append(tool) - # Update kwargs with converted tools kwargs["tools"] = converted_tools verbose_logger.debug( f"WebSearchInterception: Tools after conversion: {[t.get('name') for t in converted_tools]}" ) - # Convert stream=True to stream=False for WebSearch interception + # Also convert here for direct callers that bypass the deployment hook. if kwargs.get("stream"): verbose_logger.debug( "WebSearchInterception: Converting stream=True to stream=False" diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index d117d74e4f..3da118fd34 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -187,11 +187,9 @@ async def anthropic_messages( """ Async: Make llm api request in Anthropic /messages API spec """ - # Save original stream flag before pre-request hooks can convert it. - # The websearch interception hook converts stream=True → stream=False - # for the agentic loop, but the short-circuit path needs to know - # whether the caller originally requested streaming. - original_stream = stream + original_stream = stream or kwargs.get( + "_websearch_interception_converted_stream", False + ) # Execute pre-request hooks to allow CustomLoggers to modify request request_kwargs = await _execute_pre_request_hooks( diff --git a/litellm/utils.py b/litellm/utils.py index f902644e76..38be20488f 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -1811,6 +1811,11 @@ def client(original_function): # noqa: PLR0915 if modified_kwargs is not None: kwargs = modified_kwargs + # Sync logging_obj.stream after deployment hooks (they may convert it). + _hook_stream = kwargs.get("stream") + if _hook_stream is not None and logging_obj.stream != _hook_stream: + logging_obj.stream = _hook_stream + kwargs["litellm_logging_obj"] = logging_obj ## LOAD CREDENTIALS load_credentials_from_list(kwargs) diff --git a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py index 020c171a66..4afb948e47 100644 --- a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py +++ b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py @@ -4,7 +4,7 @@ Unit tests for WebSearch Interception Handler Tests the WebSearchInterceptionLogger class and helper functions. """ -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock import pytest @@ -273,3 +273,49 @@ async def test_async_pre_call_deployment_hook_provider_derived_from_model_name() # Full kwargs preserved assert result["model"] == "openai/gpt-4o-mini" assert result["api_key"] == "fake-key" + + +@pytest.mark.asyncio +async def test_deployment_hook_converts_stream_and_logging_obj_syncs(): + """ + Regression test: websearch interception with stream=True must not skip logging. + + Before the fix, the stream conversion only happened in async_pre_request_hook + (inside the anthropic_messages function scope). wrapper_async still saw + stream=True, took the streaming early-return path, and skipped all spend/cost + logging. The fix moves stream conversion into the deployment hook so + wrapper_async sees stream=False, and then syncs logging_obj.stream. + + This test verifies: + 1. The deployment hook sets stream=False and the converted flag. + 2. wrapper_async syncs logging_obj.stream after the hook runs. + """ + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"]) + + kwargs = { + "model": "anthropic.claude-opus-4-6-20250219-v1:0", + "messages": [{"role": "user", "content": "Search for LiteLLM"}], + "tools": [ + {"type": "web_search_20250305", "name": "web_search", "max_uses": 3}, + ], + "custom_llm_provider": "bedrock", + "stream": True, + } + + result = await logger.async_pre_call_deployment_hook(kwargs=kwargs, call_type=None) + + assert result is not None + assert result["stream"] is False + assert result["_websearch_interception_converted_stream"] is True + + # Simulate what wrapper_async does after the deployment hook: + # logging_obj.stream was set to True during function_setup (before hook). + # After the hook, wrapper_async must sync it. + logging_obj = MagicMock() + logging_obj.stream = True # original value from function_setup + + _hook_stream = result.get("stream") + if _hook_stream is not None and logging_obj.stream != _hook_stream: + logging_obj.stream = _hook_stream + + assert logging_obj.stream is False From 2ea6e89b2c17f897a405e0a700ab8a6f2eb3496c Mon Sep 17 00:00:00 2001 From: Milan Date: Fri, 10 Apr 2026 21:06:44 +0300 Subject: [PATCH 02/19] fix(a2a): default create_a2a_client timeout to DEFAULT_A2A_AGENT_TIMEOUT Align with aget_agent_card and the DEFAULT_A2A_AGENT_TIMEOUT env var so A2A message/send uses the same default as agent card fetch instead of a hardcoded 60s HTTP read timeout. Also correct aget_agent_card docstring for the timeout parameter. Made-with: Cursor --- litellm/a2a_protocol/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/a2a_protocol/main.py b/litellm/a2a_protocol/main.py index c86549da77..6154c82880 100644 --- a/litellm/a2a_protocol/main.py +++ b/litellm/a2a_protocol/main.py @@ -615,7 +615,7 @@ async def asend_message_streaming( # noqa: PLR0915 async def create_a2a_client( base_url: str, - timeout: float = 60.0, + timeout: float = DEFAULT_A2A_AGENT_TIMEOUT, extra_headers: Optional[Dict[str, str]] = None, ) -> "A2AClientType": """ @@ -626,7 +626,7 @@ async def create_a2a_client( Args: base_url: The base URL of the A2A agent (e.g., "http://localhost:10001") - timeout: Request timeout in seconds (default: 60.0) + timeout: Request timeout in seconds (default: ``DEFAULT_A2A_AGENT_TIMEOUT`` / env ``DEFAULT_A2A_AGENT_TIMEOUT``) extra_headers: Optional additional headers to include in requests Returns: @@ -711,7 +711,7 @@ async def aget_agent_card( Args: base_url: The base URL of the A2A agent (e.g., "http://localhost:10001") - timeout: Request timeout in seconds (default: 60.0) + timeout: Request timeout in seconds (default: ``DEFAULT_A2A_AGENT_TIMEOUT`` / env ``DEFAULT_A2A_AGENT_TIMEOUT``) extra_headers: Optional additional headers to include in requests Returns: From 824269d585c83226187920ee053c2adba6ca740f Mon Sep 17 00:00:00 2001 From: Milan Date: Fri, 10 Apr 2026 21:10:28 +0300 Subject: [PATCH 03/19] test(a2a): assert create_a2a_client default timeout uses DEFAULT_A2A_AGENT_TIMEOUT Made-with: Cursor --- .../test_agent_header_isolation.py | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/proxy/agent_endpoints/test_agent_header_isolation.py b/tests/test_litellm/proxy/agent_endpoints/test_agent_header_isolation.py index 13a9adc3c6..c85987c19c 100644 --- a/tests/test_litellm/proxy/agent_endpoints/test_agent_header_isolation.py +++ b/tests/test_litellm/proxy/agent_endpoints/test_agent_header_isolation.py @@ -4,6 +4,9 @@ Tests that prove header isolation between agents. Before the fix these tests FAIL — agent A's headers bleed into agent B because create_a2a_client mutates a globally cached httpx client. After the fix they pass. + +Also includes direct unit tests for create_a2a_client (fresh httpx client +per call; default timeout uses DEFAULT_A2A_AGENT_TIMEOUT). """ import sys @@ -11,6 +14,8 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest +from litellm.constants import DEFAULT_A2A_AGENT_TIMEOUT + # --------------------------------------------------------------------------- # Helpers @@ -199,7 +204,7 @@ async def test_each_agent_gets_only_its_own_static_headers(): # --------------------------------------------------------------------------- -# Unit test: create_a2a_client uses a fresh httpx client per call +# Unit tests: create_a2a_client (httpx client per call + timeout defaults) # --------------------------------------------------------------------------- @@ -246,3 +251,81 @@ async def test_create_a2a_client_uses_fresh_httpx_client(): assert created_clients[0] is not created_clients[1], ( "create_a2a_client reused a cached httpx client — headers will bleed between agents" ) + + +@pytest.mark.asyncio +async def test_create_a2a_client_default_timeout_matches_constant(): + """When timeout is omitted, httpx client params must use DEFAULT_A2A_AGENT_TIMEOUT.""" + from litellm.a2a_protocol.main import create_a2a_client + + captured: dict = {} + + def _capture_get_async_httpx_client(llm_provider, params, **kwargs): + captured["params"] = params + handler = MagicMock() + handler.client = MagicMock() + handler.client.headers = MagicMock() + return handler + + fake_agent_card = MagicMock() + fake_agent_card.name = "test-agent" + + class _FakeResolver: + def __init__(self, **kw): + pass + + async def get_agent_card(self): + return fake_agent_card + + class _FakeA2AClient: + def __init__(self, httpx_client, agent_card): + pass + + with patch("litellm.a2a_protocol.main.A2A_SDK_AVAILABLE", True), patch( + "litellm.a2a_protocol.main.get_async_httpx_client", + side_effect=_capture_get_async_httpx_client, + ), patch("litellm.a2a_protocol.main.A2ACardResolver", _FakeResolver), patch( + "litellm.a2a_protocol.main._A2AClient", _FakeA2AClient + ): + await create_a2a_client(base_url="http://127.0.0.1:9") + + assert captured["params"]["timeout"] == DEFAULT_A2A_AGENT_TIMEOUT + + +@pytest.mark.asyncio +async def test_create_a2a_client_explicit_timeout_overrides_default(): + """Explicit timeout= must be passed through to the httpx client params.""" + from litellm.a2a_protocol.main import create_a2a_client + + captured: dict = {} + + def _capture_get_async_httpx_client(llm_provider, params, **kwargs): + captured["params"] = params + handler = MagicMock() + handler.client = MagicMock() + handler.client.headers = MagicMock() + return handler + + fake_agent_card = MagicMock() + fake_agent_card.name = "test-agent" + + class _FakeResolver: + def __init__(self, **kw): + pass + + async def get_agent_card(self): + return fake_agent_card + + class _FakeA2AClient: + def __init__(self, httpx_client, agent_card): + pass + + with patch("litellm.a2a_protocol.main.A2A_SDK_AVAILABLE", True), patch( + "litellm.a2a_protocol.main.get_async_httpx_client", + side_effect=_capture_get_async_httpx_client, + ), patch("litellm.a2a_protocol.main.A2ACardResolver", _FakeResolver), patch( + "litellm.a2a_protocol.main._A2AClient", _FakeA2AClient + ): + await create_a2a_client(base_url="http://127.0.0.1:9", timeout=42.5) + + assert captured["params"]["timeout"] == 42.5 From 506de4527e9e1d99797eba6ebdba944796a09db0 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 10 Apr 2026 11:42:55 -0700 Subject: [PATCH 04/19] feat(anthropic): add AnthropicAdvisorTool type and ADVISOR_TOOL_2026_03_01 beta header enum --- litellm/types/llms/anthropic.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index 37044c2b4f..b9e1ebd417 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -126,6 +126,16 @@ class AnthropicToolSearchToolBM25(TypedDict, total=False): input_examples: Optional[List[Dict[str, Any]]] +class AnthropicAdvisorTool(TypedDict, total=False): + """Advisor tool — pairs a fast executor model with a high-intelligence advisor model.""" + + type: Required[Literal["advisor_20260301"]] + name: Required[Literal["advisor"]] + model: Required[str] + max_uses: Optional[int] + caching: Optional[dict] + + class ToolReference(TypedDict, total=False): """Reference to a tool that should be expanded from deferred tools.""" @@ -165,6 +175,7 @@ AllAnthropicToolsValues = Union[ AnthropicMemoryTool, AnthropicToolSearchToolRegex, AnthropicToolSearchToolBM25, + AnthropicAdvisorTool, ] @@ -654,6 +665,7 @@ class ANTHROPIC_BETA_HEADER_VALUES(str, Enum): STRUCTURED_OUTPUT_2025_09_25 = "structured-outputs-2025-11-13" ADVANCED_TOOL_USE_2025_11_20 = "advanced-tool-use-2025-11-20" FAST_MODE_2026_02_01 = "fast-mode-2026-02-01" + ADVISOR_TOOL_2026_03_01 = "advisor-tool-2026-03-01" # Tool search beta header constant (for Anthropic direct API and Microsoft Foundry) From a30a538ae5970da1fc91a0226704a49177277582 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 10 Apr 2026 11:42:58 -0700 Subject: [PATCH 05/19] feat(anthropic): support advisor_20260301 tool and auto-inject advisor beta header --- litellm/llms/anthropic/chat/transformation.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 9a99f9efc8..28f543c1cd 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -508,6 +508,23 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): type="tool_search_tool_bm25_20251119", name=tool_name, ) + elif tool["type"] == "advisor_20260301": + from litellm.types.llms.anthropic import AnthropicAdvisorTool + + _tool_dict = cast(dict, tool) + advisor_model = _tool_dict.get("model") + if not isinstance(advisor_model, str): + raise ValueError("Advisor tool must have a valid model") + _advisor_tool = AnthropicAdvisorTool( + type="advisor_20260301", + name="advisor", + model=advisor_model, + ) + if _tool_dict.get("max_uses") is not None: + _advisor_tool["max_uses"] = _tool_dict["max_uses"] + if _tool_dict.get("caching") is not None: + _advisor_tool["caching"] = _tool_dict["caching"] + returned_tool = _advisor_tool # type: ignore[assignment] if returned_tool is None and mcp_server is None: raise ValueError(f"Unsupported tool type: {tool['type']}") @@ -1311,6 +1328,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): self._ensure_beta_header( headers, ANTHROPIC_BETA_HEADER_VALUES.FAST_MODE_2026_02_01.value ) + for tool in _tools: + if tool.get("type") == "advisor_20260301": + self._ensure_beta_header( + headers, ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value + ) + break return headers def transform_request( From 0f9eba4de0b69165c33bba9e725d5d7aa6ee66ff Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 10 Apr 2026 11:43:02 -0700 Subject: [PATCH 06/19] test(anthropic): add advisor tool transformation tests --- .../test_anthropic_chat_transformation.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 10a3c10736..8fa679a7ac 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -12,6 +12,7 @@ from litellm.llms.anthropic.chat.transformation import AnthropicConfig from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( AnthropicMessagesConfig, ) +from litellm.types.llms.anthropic import ANTHROPIC_BETA_HEADER_VALUES from litellm.types.utils import ServerToolUse @@ -3399,3 +3400,106 @@ def test_extract_response_content_thinking_block_null_thinking(): assert len(thinking_blocks) == 1 assert thinking_blocks[0]["thinking"] == "Let me think..." assert "Done" in text + + +def test_advisor_tool_map_tool_helper(): + """advisor_20260301 tool type should not raise ValueError.""" + config = AnthropicConfig() + tool = { + "type": "advisor_20260301", + "name": "advisor", + "model": "claude-opus-4-6", + } + returned_tool, mcp_server = config._map_tool_helper(tool) # type: ignore + assert returned_tool is not None + assert returned_tool["type"] == "advisor_20260301" + assert returned_tool["model"] == "claude-opus-4-6" + assert mcp_server is None + + +def test_advisor_tool_map_tool_helper_with_optional_fields(): + """advisor_20260301 tool with max_uses and caching should be mapped correctly.""" + config = AnthropicConfig() + tool = { + "type": "advisor_20260301", + "name": "advisor", + "model": "claude-opus-4-6", + "max_uses": 3, + "caching": {"type": "ephemeral", "ttl": "5m"}, + } + returned_tool, _ = config._map_tool_helper(tool) # type: ignore + assert returned_tool is not None + assert returned_tool["max_uses"] == 3 + assert returned_tool["caching"] == {"type": "ephemeral", "ttl": "5m"} + + +def test_advisor_tool_map_tool_helper_missing_model(): + """advisor_20260301 without model should raise ValueError.""" + config = AnthropicConfig() + tool = {"type": "advisor_20260301", "name": "advisor"} + with pytest.raises(ValueError, match="valid model"): + config._map_tool_helper(tool) # type: ignore + + +def test_advisor_beta_header_injected(): + """advisor-tool-2026-03-01 beta header is auto-injected when advisor tool is present.""" + config = AnthropicConfig() + headers: dict = {} + optional_params = { + "tools": [ + { + "type": "advisor_20260301", + "name": "advisor", + "model": "claude-opus-4-6", + } + ] + } + result = config.update_headers_with_optional_anthropic_beta(headers, optional_params) + assert ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value in result.get( + "anthropic-beta", "" + ) + + +def test_advisor_beta_header_not_injected_without_tool(): + """advisor-tool-2026-03-01 beta header is NOT added when advisor tool is absent.""" + config = AnthropicConfig() + headers: dict = {} + optional_params: dict = {"tools": []} + result = config.update_headers_with_optional_anthropic_beta(headers, optional_params) + assert "advisor-tool-2026-03-01" not in result.get("anthropic-beta", "") + + +def test_advisor_tool_result_preserved_in_response(): + """advisor_tool_result blocks are preserved in tool_results (not dropped).""" + config = AnthropicConfig() + completion_response = { + "content": [ + {"type": "text", "text": "Consulting advisor."}, + { + "type": "server_tool_use", + "id": "srvtoolu_abc123", + "name": "advisor", + "input": {}, + }, + { + "type": "advisor_tool_result", + "tool_use_id": "srvtoolu_abc123", + "content": {"type": "advisor_result", "text": "Use a channel-based pattern."}, + }, + {"type": "text", "text": "Here is the implementation."}, + ] + } + text, _, _, _, tool_calls, _, tool_results, _ = config.extract_response_content( + completion_response + ) + assert "Consulting advisor." in text + assert "Here is the implementation." in text + # server_tool_use (advisor) should be a tool_call + assert len(tool_calls) == 1 + assert tool_calls[0]["function"]["name"] == "advisor" + assert tool_calls[0]["id"] == "srvtoolu_abc123" + # advisor_tool_result should be in tool_results + assert tool_results is not None + assert len(tool_results) == 1 + assert tool_results[0]["type"] == "advisor_tool_result" + assert tool_results[0]["tool_use_id"] == "srvtoolu_abc123" From ed4aa8423537864a96e81f26d2b8ef7e16ec3d09 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 10 Apr 2026 11:48:36 -0700 Subject: [PATCH 07/19] feat(anthropic/messages): auto-inject advisor-tool-2026-03-01 beta header in /messages path --- .../messages/transformation.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 9b60a58260..d43350ec7e 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -324,6 +324,16 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if optional_params.get("speed") == "fast": beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.FAST_MODE_2026_02_01.value) + # Check for advisor tool + tools = optional_params.get("tools") + if tools: + for tool in tools: + if isinstance(tool, dict) and tool.get("type") == "advisor_20260301": + beta_values.add( + ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value + ) + break + # Check for tool search tools tools = optional_params.get("tools") if tools: From 55f0e6605b2d3f0a7d328aaea3131fea73b83012 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 10 Apr 2026 11:48:39 -0700 Subject: [PATCH 08/19] test(anthropic): add advisor tool tests for /messages beta header path --- .../test_anthropic_chat_transformation.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 8fa679a7ac..2e30ad0c98 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -3503,3 +3503,29 @@ def test_advisor_tool_result_preserved_in_response(): assert len(tool_results) == 1 assert tool_results[0]["type"] == "advisor_tool_result" assert tool_results[0]["tool_use_id"] == "srvtoolu_abc123" + + +def test_messages_path_advisor_beta_header_injected(): + """advisor-tool-2026-03-01 beta header is auto-injected in /messages path.""" + config = AnthropicMessagesConfig() + headers: dict = {} + optional_params = { + "tools": [ + { + "type": "advisor_20260301", + "name": "advisor", + "model": "claude-opus-4-6", + } + ] + } + result = config._update_headers_with_anthropic_beta(headers, optional_params) + assert "advisor-tool-2026-03-01" in result.get("anthropic-beta", "") + + +def test_messages_path_advisor_beta_header_preserved_when_user_sends_it(): + """Existing anthropic-beta headers are preserved and advisor header is merged.""" + config = AnthropicMessagesConfig() + headers: dict = {"anthropic-beta": "advisor-tool-2026-03-01"} + optional_params: dict = {"tools": []} + result = config._update_headers_with_anthropic_beta(headers, optional_params) + assert "advisor-tool-2026-03-01" in result.get("anthropic-beta", "") From 91f6d49b877790bee944a00f96c26a6edc214276 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 10 Apr 2026 12:20:52 -0700 Subject: [PATCH 09/19] feat(anthropic): register advisor-tool-2026-03-01 in beta headers config Add advisor-tool-2026-03-01 to anthropic_beta_headers_config.json so the beta headers manager forwards it to Anthropic (was being silently dropped). Mark as null for all non-native providers. --- litellm/anthropic_beta_headers_config.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/litellm/anthropic_beta_headers_config.json b/litellm/anthropic_beta_headers_config.json index df8d49ac8f..7dd5975b7b 100644 --- a/litellm/anthropic_beta_headers_config.json +++ b/litellm/anthropic_beta_headers_config.json @@ -1,6 +1,7 @@ { "description": "Mapping of Anthropic beta headers for each provider. Keys are input header names, values are provider-specific header names (or null if unsupported). Only headers present in mapping keys with non-null values can be forwarded.", "anthropic": { + "advisor-tool-2026-03-01": "advisor-tool-2026-03-01", "advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20", "bash_20241022": null, "bash_20250124": null, @@ -31,6 +32,7 @@ "web-search-2025-03-05": "web-search-2025-03-05" }, "azure_ai": { + "advisor-tool-2026-03-01": null, "advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20", "bash_20241022": null, "bash_20250124": null, @@ -60,6 +62,7 @@ "web-search-2025-03-05": "web-search-2025-03-05" }, "bedrock_converse": { + "advisor-tool-2026-03-01": null, "advanced-tool-use-2025-11-20": null, "bash_20241022": null, "bash_20250124": null, @@ -90,6 +93,7 @@ "web-search-2025-03-05": null }, "bedrock": { + "advisor-tool-2026-03-01": null, "advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19", "bash_20241022": null, "bash_20250124": null, @@ -120,6 +124,7 @@ "web-search-2025-03-05": null }, "vertex_ai": { + "advisor-tool-2026-03-01": null, "advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19", "bash_20241022": null, "bash_20250124": null, @@ -150,6 +155,7 @@ "web-search-2025-03-05": "web-search-2025-03-05" }, "databricks": { + "advisor-tool-2026-03-01": null, "advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20", "bash_20241022": null, "bash_20250124": null, From 3a89465d18aacb631d2bab74a798c15ac00d7507 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 10 Apr 2026 13:15:41 -0700 Subject: [PATCH 10/19] feat(advisor): auto-strip advisor_tool_result blocks when advisor tool absent Prevents Anthropic 400 invalid_request_error on follow-up turns where the caller has removed the advisor tool but message history still contains server_tool_use(advisor) + advisor_tool_result blocks. --- litellm/llms/anthropic/common_utils.py | 56 ++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 7d2d0a7496..1205d1afbc 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -2,7 +2,7 @@ This file contains common utils for anthropic calls. """ -from typing import Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union import httpx @@ -464,9 +464,9 @@ class AnthropicModelInfo(BaseLLMModelInfo): if web_search_tool_used: from litellm.types.llms.anthropic import ANTHROPIC_BETA_HEADER_VALUES - headers[ - "anthropic-beta" - ] = ANTHROPIC_BETA_HEADER_VALUES.WEB_SEARCH_2025_03_05.value + headers["anthropic-beta"] = ( + ANTHROPIC_BETA_HEADER_VALUES.WEB_SEARCH_2025_03_05.value + ) elif len(betas) > 0: headers["anthropic-beta"] = ",".join(betas) @@ -639,6 +639,54 @@ class AnthropicModelInfo(BaseLLMModelInfo): return AnthropicTokenCounter() +def strip_advisor_blocks_from_messages(messages: List[Any]) -> List[Any]: + """ + Remove server_tool_use (name='advisor') and advisor_tool_result blocks from + assistant message content when the advisor tool is absent from the request. + + Prevents Anthropic 400 invalid_request_error: if advisor_tool_result blocks + exist in history but the advisor tool is not in the tools array, the API rejects + the request. This happens when the user has removed the advisor tool for cost + control or on a follow-up turn. + """ + for message in messages: + if message.get("role") != "assistant": + continue + content = message.get("content") + if not isinstance(content, list): + continue + advisor_ids: set = set() + for block in content: + if ( + isinstance(block, dict) + and block.get("type") == "server_tool_use" + and block.get("name") == "advisor" + ): + bid = block.get("id") + if bid: + advisor_ids.add(bid) + if not advisor_ids: + continue + message["content"] = [ + block + for block in content + if not ( + isinstance(block, dict) + and ( + ( + block.get("type") == "server_tool_use" + and block.get("name") == "advisor" + ) + or ( + block.get("type") == "advisor_tool_result" + and block.get("tool_use_id") in advisor_ids + ) + ) + ) + ] + return messages + + def process_anthropic_headers(headers: Union[httpx.Headers, dict]) -> dict: openai_headers = {} if "anthropic-ratelimit-requests-limit" in headers: From ab8d92c14c6f0fde8f5469f619f38bf09f495247 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 10 Apr 2026 13:15:45 -0700 Subject: [PATCH 11/19] feat(advisor): call strip_advisor_blocks in chat/completions transform_request --- litellm/llms/anthropic/chat/transformation.py | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 28f543c1cd..185e1b5de3 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -75,7 +75,12 @@ from litellm.utils import ( token_counter, ) -from ..common_utils import AnthropicError, AnthropicModelInfo, process_anthropic_headers +from ..common_utils import ( + AnthropicError, + AnthropicModelInfo, + process_anthropic_headers, + strip_advisor_blocks_from_messages, +) if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -981,11 +986,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): if mcp_servers: optional_params["mcp_servers"] = mcp_servers elif param == "tool_choice" or param == "parallel_tool_calls": - _tool_choice: Optional[ - AnthropicMessagesToolChoice - ] = self._map_tool_choice( - tool_choice=non_default_params.get("tool_choice"), - parallel_tool_use=non_default_params.get("parallel_tool_calls"), + _tool_choice: Optional[AnthropicMessagesToolChoice] = ( + self._map_tool_choice( + tool_choice=non_default_params.get("tool_choice"), + parallel_tool_use=non_default_params.get("parallel_tool_calls"), + ) ) if _tool_choice is not None: @@ -1083,9 +1088,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): self.map_openai_context_management_to_anthropic(value) ) if anthropic_context_management is not None: - optional_params[ - "context_management" - ] = anthropic_context_management + optional_params["context_management"] = ( + anthropic_context_management + ) elif param == "speed" and isinstance(value, str): # Pass through Anthropic-specific speed parameter for fast mode optional_params["speed"] = value @@ -1159,9 +1164,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): text=system_message_block["content"], ) if "cache_control" in system_message_block: - anthropic_system_message_content[ - "cache_control" - ] = system_message_block["cache_control"] + anthropic_system_message_content["cache_control"] = ( + system_message_block["cache_control"] + ) anthropic_system_message_list.append( anthropic_system_message_content ) @@ -1185,9 +1190,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) ) if "cache_control" in _content: - anthropic_system_message_content[ - "cache_control" - ] = _content["cache_control"] + anthropic_system_message_content["cache_control"] = ( + _content["cache_control"] + ) anthropic_system_message_list.append( anthropic_system_message_content @@ -1413,6 +1418,16 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): message="{}\nReceived Messages={}".format(str(e), messages), ) # don't use verbose_logger.exception, if exception is raised + ## Auto-strip advisor blocks from history if advisor tool is absent. + ## Prevents Anthropic 400: advisor_tool_result in history requires advisor tool. + _all_tools = optional_params.get("tools") or [] + _has_advisor = any( + isinstance(t, dict) and t.get("type") == "advisor_20260301" + for t in _all_tools + ) + if not _has_advisor: + anthropic_messages = strip_advisor_blocks_from_messages(anthropic_messages) + ## Add code_execution tool if container_upload is in messages _tools = ( cast( @@ -1500,9 +1515,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) return _message - def extract_response_content( - self, completion_response: dict - ) -> Tuple[ + def extract_response_content(self, completion_response: dict) -> Tuple[ str, Optional[List[Any]], Optional[ @@ -1796,9 +1809,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): code_interpreter_results = self._build_code_interpreter_results( tool_results, code_by_id, container_id ) - provider_specific_fields[ - "code_interpreter_results" - ] = code_interpreter_results + provider_specific_fields["code_interpreter_results"] = ( + code_interpreter_results + ) container = completion_response.get("container") if container is not None: From 9742bcd3ae4642eb4a9188ce81c63515125416f6 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 10 Apr 2026 13:15:48 -0700 Subject: [PATCH 12/19] feat(advisor): call strip_advisor_blocks in /messages transform path --- .../messages/transformation.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index d43350ec7e..6fac6b5e35 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -21,6 +21,7 @@ from ...common_utils import ( AnthropicError, AnthropicModelInfo, optionally_handle_anthropic_oauth, + strip_advisor_blocks_from_messages, ) DEFAULT_ANTHROPIC_API_VERSION = "2023-06-01" @@ -208,12 +209,22 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): ) ) if transformed_context_management is not None: - anthropic_messages_optional_request_params[ - "context_management" - ] = transformed_context_management + anthropic_messages_optional_request_params["context_management"] = ( + transformed_context_management + ) ####### get required params for all anthropic messages requests ###### verbose_logger.debug(f"TRANSFORMATION DEBUG - Messages: {messages}") + + # Auto-strip advisor blocks from history if advisor tool is absent. + # Prevents Anthropic 400: advisor_tool_result in history requires advisor tool. + _tools = anthropic_messages_optional_request_params.get("tools") or [] + _has_advisor = any( + isinstance(t, dict) and t.get("type") == "advisor_20260301" for t in _tools + ) + if not _has_advisor: + messages = strip_advisor_blocks_from_messages(messages) # type: ignore[assignment] + anthropic_messages_request: AnthropicMessagesRequest = AnthropicMessagesRequest( messages=messages, max_tokens=max_tokens, From 318196f793149257b16019dc5dce1a3dba1c6b9e Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 10 Apr 2026 13:15:51 -0700 Subject: [PATCH 13/19] test(advisor): add tests for auto-strip advisor_tool_result blocks --- .../test_anthropic_chat_transformation.py | 82 +++++++++++++++++-- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 2e30ad0c98..6b1b9ced24 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -3368,7 +3368,9 @@ def test_extract_response_content_thinking_block_null_thinking(): text, _, thinking_blocks, _, _, _, _, _ = config.extract_response_content( completion_response_null ) - assert thinking_blocks is not None, "thinking blocks should not be None when thinking=null" + assert ( + thinking_blocks is not None + ), "thinking blocks should not be None when thinking=null" assert len(thinking_blocks) == 1 assert "Hello" in text @@ -3382,7 +3384,9 @@ def test_extract_response_content_thinking_block_null_thinking(): text, _, thinking_blocks, _, _, _, _, _ = config.extract_response_content( completion_response_missing ) - assert thinking_blocks is not None, "thinking blocks should not be None when thinking key is absent" + assert ( + thinking_blocks is not None + ), "thinking blocks should not be None when thinking key is absent" assert len(thinking_blocks) == 1 assert "World" in text @@ -3454,7 +3458,9 @@ def test_advisor_beta_header_injected(): } ] } - result = config.update_headers_with_optional_anthropic_beta(headers, optional_params) + result = config.update_headers_with_optional_anthropic_beta( + headers, optional_params + ) assert ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value in result.get( "anthropic-beta", "" ) @@ -3465,7 +3471,9 @@ def test_advisor_beta_header_not_injected_without_tool(): config = AnthropicConfig() headers: dict = {} optional_params: dict = {"tools": []} - result = config.update_headers_with_optional_anthropic_beta(headers, optional_params) + result = config.update_headers_with_optional_anthropic_beta( + headers, optional_params + ) assert "advisor-tool-2026-03-01" not in result.get("anthropic-beta", "") @@ -3484,7 +3492,10 @@ def test_advisor_tool_result_preserved_in_response(): { "type": "advisor_tool_result", "tool_use_id": "srvtoolu_abc123", - "content": {"type": "advisor_result", "text": "Use a channel-based pattern."}, + "content": { + "type": "advisor_result", + "text": "Use a channel-based pattern.", + }, }, {"type": "text", "text": "Here is the implementation."}, ] @@ -3529,3 +3540,64 @@ def test_messages_path_advisor_beta_header_preserved_when_user_sends_it(): optional_params: dict = {"tools": []} result = config._update_headers_with_anthropic_beta(headers, optional_params) assert "advisor-tool-2026-03-01" in result.get("anthropic-beta", "") + + +def test_strip_advisor_blocks_when_no_advisor_tool(): + """ + Auto-strip removes server_tool_use(advisor) + advisor_tool_result blocks when + advisor tool is absent, preventing Anthropic 400 on follow-up turns. + """ + from litellm.llms.anthropic.common_utils import strip_advisor_blocks_from_messages + + messages = [ + {"role": "user", "content": "Build a worker pool."}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Let me consult the advisor."}, + { + "type": "server_tool_use", + "id": "srvtoolu_abc123", + "name": "advisor", + "input": {}, + }, + { + "type": "advisor_tool_result", + "tool_use_id": "srvtoolu_abc123", + "content": {"type": "advisor_result", "text": "Use channels."}, + }, + {"type": "text", "text": "Here is the implementation."}, + ], + }, + ] + result = strip_advisor_blocks_from_messages(messages) + assistant_content = result[1]["content"] + types = [b["type"] for b in assistant_content] + assert "server_tool_use" not in types + assert "advisor_tool_result" not in types + assert "text" in types + assert len(assistant_content) == 2 + + +def test_strip_advisor_blocks_no_op_when_no_advisor_blocks(): + """strip_advisor_blocks_from_messages is a no-op when no advisor blocks exist.""" + from litellm.llms.anthropic.common_utils import strip_advisor_blocks_from_messages + + messages = [ + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Hi there"}, + { + "type": "tool_use", + "id": "toolu_abc", + "name": "get_weather", + "input": {"location": "SF"}, + }, + ], + }, + ] + original_content = [dict(b) for b in messages[1]["content"]] + result = strip_advisor_blocks_from_messages(messages) + assert result[1]["content"] == original_content From ed973c049f07047eb820f323ef4f1ede669b36bb Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 10 Apr 2026 13:15:54 -0700 Subject: [PATCH 14/19] docs: add Advisor Tool documentation page --- .../docs/providers/anthropic_advisor_tool.md | 422 ++++++++++++++++++ docs/my-website/sidebars.js | 2 + 2 files changed, 424 insertions(+) create mode 100644 docs/my-website/docs/providers/anthropic_advisor_tool.md diff --git a/docs/my-website/docs/providers/anthropic_advisor_tool.md b/docs/my-website/docs/providers/anthropic_advisor_tool.md new file mode 100644 index 0000000000..3cb87ffdf3 --- /dev/null +++ b/docs/my-website/docs/providers/anthropic_advisor_tool.md @@ -0,0 +1,422 @@ +# Advisor Tool + +Pair a faster executor model with a higher-intelligence advisor model that provides strategic guidance mid-generation. + +The advisor tool lets a fast, lower-cost executor model (Sonnet or Haiku) consult a high-intelligence advisor model (Opus 4.6) mid-generation. The advisor reads the full conversation and produces a plan or course correction — typically 400–700 text tokens — and the executor continues with the task. + +This pattern is well-suited for long-horizon agentic workloads (coding agents, computer use, multi-step research) where most turns are mechanical but having an excellent plan is crucial. You get close to advisor-solo quality while the bulk of token generation happens at executor-model rates. + +:::info Beta + +The advisor tool is in beta. Include `anthropic-beta: advisor-tool-2026-03-01` in your requests — LiteLLM adds this automatically when it detects the advisor tool in your `tools` array. + +::: + +## Supported Providers + +| Provider | Chat Completions API | Messages API | +|----------|---------------------|--------------| +| **Anthropic API** | ✅ | ✅ | +| **Azure Anthropic** | ❌ (coming soon) | ❌ (coming soon) | +| **Google Cloud Vertex AI** | ❌ (coming soon) | ❌ (coming soon) | +| **Amazon Bedrock** | ❌ (coming soon) | ❌ (coming soon) | + +## Model Compatibility + +The executor and advisor models must form a valid pair. Currently the only supported advisor model is `claude-opus-4-6`. + +| Executor | Advisor | +|----------|---------| +| `claude-haiku-4-5-20251001` | `claude-opus-4-6` | +| `claude-sonnet-4-6` | `claude-opus-4-6` | +| `claude-opus-4-6` | `claude-opus-4-6` | + +--- + +## Chat Completions API + +### SDK Usage + +#### Basic Example + +```python showLineNumbers title="Advisor Tool — litellm.completion()" +import litellm + +response = litellm.completion( + model="anthropic/claude-sonnet-4-6", + messages=[ + {"role": "user", "content": "Build a concurrent worker pool in Go with graceful shutdown."} + ], + tools=[ + { + "type": "advisor_20260301", + "name": "advisor", + "model": "claude-opus-4-6", + } + ], + max_tokens=4096, +) + +print(response.choices[0].message.content) +``` + +#### With Optional Parameters + +```python showLineNumbers title="Advisor Tool with max_uses and caching" +import litellm + +response = litellm.completion( + model="anthropic/claude-sonnet-4-6", + messages=[ + {"role": "user", "content": "Build a REST API with authentication in Python."} + ], + tools=[ + { + "type": "advisor_20260301", + "name": "advisor", + "model": "claude-opus-4-6", + "max_uses": 3, # cap advisor calls per request + "caching": {"type": "ephemeral", "ttl": "5m"}, # enable for 3+ calls per conversation + } + ], + max_tokens=4096, +) +``` + +#### Streaming + +```python showLineNumbers title="Streaming with Advisor Tool" +import litellm + +response = litellm.completion( + model="anthropic/claude-sonnet-4-6", + messages=[ + {"role": "user", "content": "Implement a distributed rate limiter."} + ], + tools=[ + { + "type": "advisor_20260301", + "name": "advisor", + "model": "claude-opus-4-6", + } + ], + max_tokens=4096, + stream=True, +) + +for chunk in response: + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="") +``` + +:::note Streaming behavior + +The advisor sub-inference does not stream. The executor's stream pauses while the advisor runs, then the full advisor result arrives in a single event. Executor output resumes streaming afterward. + +::: + +#### Multi-Turn Conversation + +```python showLineNumbers title="Multi-Turn with Advisor Tool" +import litellm + +tools = [ + { + "type": "advisor_20260301", + "name": "advisor", + "model": "claude-opus-4-6", + } +] + +messages = [ + {"role": "user", "content": "Build a concurrent worker pool in Go with graceful shutdown."} +] + +response = litellm.completion( + model="anthropic/claude-sonnet-4-6", + messages=messages, + tools=tools, + max_tokens=4096, +) + +# Append the full response (includes server_tool_use + advisor_tool_result blocks) +messages.append({"role": "assistant", "content": response.choices[0].message.content}) + +# Continue the conversation — keep the same tools array +messages.append({"role": "user", "content": "Now add a max-in-flight limit of 10."}) + +response2 = litellm.completion( + model="anthropic/claude-sonnet-4-6", + messages=messages, + tools=tools, + max_tokens=4096, +) +``` + +:::tip Auto-strip on follow-up turns + +LiteLLM automatically strips `advisor_tool_result` blocks from message history when the advisor tool is not present in the current request. This prevents the Anthropic 400 error that would otherwise occur. + +::: + +### AI Gateway Usage + +#### Proxy Configuration + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: claude-sonnet + litellm_params: + model: anthropic/claude-sonnet-4-6 + api_key: os.environ/ANTHROPIC_API_KEY +``` + +#### Client Request via Proxy + +```python showLineNumbers title="Advisor Tool via AI Gateway" +from openai import OpenAI + +client = OpenAI( + api_key="your-litellm-proxy-key", + base_url="http://0.0.0.0:4000/v1" +) + +response = client.chat.completions.create( + model="claude-sonnet", + messages=[ + {"role": "user", "content": "Implement a distributed rate limiter in Python."} + ], + tools=[ + { + "type": "advisor_20260301", + "name": "advisor", + "model": "claude-opus-4-6", + } + ], + max_tokens=4096, +) +``` + +--- + +## Messages API + +### SDK Usage + +#### Basic Example + +```python showLineNumbers title="Advisor Tool — litellm.anthropic.messages" +import asyncio +import litellm + +async def main(): + response = await litellm.anthropic.messages.acreate( + model="anthropic/claude-sonnet-4-6", + messages=[ + {"role": "user", "content": "Build a concurrent worker pool in Go with graceful shutdown."} + ], + tools=[ + { + "type": "advisor_20260301", + "name": "advisor", + "model": "claude-opus-4-6", + } + ], + max_tokens=4096, + ) + print(response) + +asyncio.run(main()) +``` + +#### Streaming + +```python showLineNumbers title="Messages API Streaming with Advisor Tool" +import asyncio +import json +import litellm + +async def main(): + response = await litellm.anthropic.messages.acreate( + model="anthropic/claude-sonnet-4-6", + messages=[ + {"role": "user", "content": "Implement a distributed rate limiter."} + ], + tools=[ + { + "type": "advisor_20260301", + "name": "advisor", + "model": "claude-opus-4-6", + } + ], + max_tokens=4096, + stream=True, + ) + + async for chunk in response: + if isinstance(chunk, bytes): + for line in chunk.decode("utf-8").split("\n"): + if line.startswith("data: "): + try: + print(json.loads(line[6:])) + except json.JSONDecodeError: + pass + +asyncio.run(main()) +``` + +### AI Gateway Usage + +#### Proxy Configuration + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: claude-sonnet + litellm_params: + model: anthropic/claude-sonnet-4-6 + api_key: os.environ/ANTHROPIC_API_KEY +``` + +#### Client Request via Proxy (Anthropic SDK) + +```python showLineNumbers title="Advisor Tool via AI Gateway (Anthropic SDK)" +import anthropic + +client = anthropic.Anthropic( + api_key="your-litellm-proxy-key", + base_url="http://0.0.0.0:4000" +) + +response = client.beta.messages.create( + model="claude-sonnet", + max_tokens=4096, + betas=["advisor-tool-2026-03-01"], + messages=[ + {"role": "user", "content": "Build a concurrent worker pool in Go with graceful shutdown."} + ], + tools=[ + { + "type": "advisor_20260301", + "name": "advisor", + "model": "claude-opus-4-6", + } + ], +) +print(response) +``` + +--- + +## Response Structure + +A successful advisor call returns `server_tool_use` and `advisor_tool_result` blocks in the assistant content: + +```json title="Response with advisor blocks" +{ + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Let me consult the advisor on this." + }, + { + "type": "server_tool_use", + "id": "srvtoolu_abc123", + "name": "advisor", + "input": {} + }, + { + "type": "advisor_tool_result", + "tool_use_id": "srvtoolu_abc123", + "content": { + "type": "advisor_result", + "text": "Use a channel-based coordination pattern. The tricky part is draining in-flight work during shutdown: close the input channel first, then wait on a WaitGroup..." + } + }, + { + "type": "text", + "text": "Here's the implementation using a channel-based coordination pattern..." + } + ] +} +``` + +Pass the full assistant content, including advisor blocks, back on subsequent turns. LiteLLM handles this automatically through `provider_specific_fields`. + +--- + +## Cost Control + +Advisor calls run as a separate sub-inference billed at the advisor model's rates. Usage is reported in `usage.iterations[]`: + +```json title="Usage with advisor sub-inference" +{ + "usage": { + "input_tokens": 412, + "output_tokens": 531, + "iterations": [ + { + "type": "message", + "input_tokens": 412, + "output_tokens": 89 + }, + { + "type": "advisor_message", + "model": "claude-opus-4-6", + "input_tokens": 823, + "output_tokens": 1612 + }, + { + "type": "message", + "input_tokens": 1348, + "output_tokens": 442 + } + ] + } +} +``` + +Top-level `usage` reflects executor tokens only. Advisor tokens appear in `iterations` entries with `type: "advisor_message"` and are billed at Opus rates. + +**Tips:** +- Enable `caching` on the tool definition only when you expect 3+ advisor calls per conversation; it costs more than it saves below that threshold. +- Use `max_uses` to cap advisor calls per request. Once reached, the executor continues without further advice. +- For conversation-level caps, count advisor calls client-side. When you reach your limit, remove the advisor tool from `tools`. + +--- + +## Recommended System Prompt + +For coding and agent tasks, Anthropic recommends prepending these blocks to your system prompt for consistent advisor timing and optimal cost/quality: + +```text title="Timing guidance (prepend to system prompt)" +You have access to an `advisor` tool backed by a stronger reviewer model. It takes NO parameters — when you call advisor(), your entire conversation history is automatically forwarded. They see the task, every tool call you've made, every result you've seen. + +Call advisor BEFORE substantive work — before writing, before committing to an interpretation, before building on an assumption. If the task requires orientation first (finding files, fetching a source, seeing what's there), do that, then call advisor. Orientation is not substantive work. Writing, editing, and declaring an answer are. + +Also call advisor: +- When you believe the task is complete. BEFORE this call, make your deliverable durable: write the file, save the result, commit the change. +- When stuck — errors recurring, approach not converging, results that don't fit. +- When considering a change of approach. + +On tasks longer than a few steps, call advisor at least once before committing to an approach and once before declaring done. On short reactive tasks where the next action is dictated by tool output you just read, you don't need to keep calling. +``` + +```text title="Advice weight guidance (add after timing block)" +Give the advice serious weight. If you follow a step and it fails empirically, or you have primary-source evidence that contradicts a specific claim, adapt. A passing self-test is not evidence the advice is wrong. + +If you've already retrieved data pointing one way and the advisor points another: don't silently switch. Surface the conflict in one more advisor call — "I found X, you suggest Y, which constraint breaks the tie?" +``` + +To reduce advisor output length by 35–45% without losing quality, add: + +```text title="Cost reduction (optional, add before timing block)" +The advisor should respond in under 100 words and use enumerated steps, not explanations. +``` + +--- + +## Additional Resources + +- [Anthropic Advisor Tool Documentation](https://platform.claude.com/docs/en/agents-and-tools/tool-use/advisor-tool) +- [LiteLLM Tool Calling Guide](https://docs.litellm.ai/docs/completion/function_call) diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 54581dceb9..7874a22878 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -861,6 +861,8 @@ const sidebars = { ] }, "providers/anthropic", + "providers/anthropic_advisor_tool", + "providers/anthropic_tool_search", "providers/aws_sagemaker", { type: "category", From d6e2a74c0ff3b9d993c26734677309252cb59da4 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 10 Apr 2026 15:08:25 -0700 Subject: [PATCH 15/19] docs: move advisor tool doc to completion/ guides section in sidebar --- .../docs/{providers => completion}/anthropic_advisor_tool.md | 0 docs/my-website/sidebars.js | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/my-website/docs/{providers => completion}/anthropic_advisor_tool.md (100%) diff --git a/docs/my-website/docs/providers/anthropic_advisor_tool.md b/docs/my-website/docs/completion/anthropic_advisor_tool.md similarity index 100% rename from docs/my-website/docs/providers/anthropic_advisor_tool.md rename to docs/my-website/docs/completion/anthropic_advisor_tool.md diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 7874a22878..b1ec952544 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -861,7 +861,6 @@ const sidebars = { ] }, "providers/anthropic", - "providers/anthropic_advisor_tool", "providers/anthropic_tool_search", "providers/aws_sagemaker", { @@ -1234,6 +1233,7 @@ const learnSidebar = { "completion/web_fetch", "completion/computer_use", "guides/code_interpreter", + "completion/anthropic_advisor_tool", "completion/message_sanitization", ], }, From 9897c6d46b7f4155b2577a0b200c2cc0e785c737 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 10 Apr 2026 15:59:46 -0700 Subject: [PATCH 16/19] refactor(advisor): replace hardcoded "advisor_20260301" with ANTHROPIC_ADVISOR_TOOL_TYPE constant --- litellm/llms/anthropic/chat/transformation.py | 9 +++++---- .../experimental_pass_through/messages/transformation.py | 9 +++++++-- litellm/types/llms/anthropic.py | 3 +++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 185e1b5de3..e78c72c13d 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -19,6 +19,7 @@ from litellm.litellm_core_utils.core_helpers import map_finish_reason from litellm.llms.base_llm.base_utils import type_to_response_format_param from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException from litellm.types.llms.anthropic import ( + ANTHROPIC_ADVISOR_TOOL_TYPE, ANTHROPIC_BETA_HEADER_VALUES, ANTHROPIC_HOSTED_TOOLS, AllAnthropicMessageValues, @@ -513,7 +514,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): type="tool_search_tool_bm25_20251119", name=tool_name, ) - elif tool["type"] == "advisor_20260301": + elif tool["type"] == ANTHROPIC_ADVISOR_TOOL_TYPE: from litellm.types.llms.anthropic import AnthropicAdvisorTool _tool_dict = cast(dict, tool) @@ -521,7 +522,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): if not isinstance(advisor_model, str): raise ValueError("Advisor tool must have a valid model") _advisor_tool = AnthropicAdvisorTool( - type="advisor_20260301", + type=ANTHROPIC_ADVISOR_TOOL_TYPE, name="advisor", model=advisor_model, ) @@ -1334,7 +1335,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): headers, ANTHROPIC_BETA_HEADER_VALUES.FAST_MODE_2026_02_01.value ) for tool in _tools: - if tool.get("type") == "advisor_20260301": + if tool.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE: self._ensure_beta_header( headers, ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value ) @@ -1422,7 +1423,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ## Prevents Anthropic 400: advisor_tool_result in history requires advisor tool. _all_tools = optional_params.get("tools") or [] _has_advisor = any( - isinstance(t, dict) and t.get("type") == "advisor_20260301" + isinstance(t, dict) and t.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE for t in _all_tools ) if not _has_advisor: diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 6fac6b5e35..46af1f7fbd 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -8,6 +8,7 @@ from litellm.llms.base_llm.anthropic_messages.transformation import ( BaseAnthropicMessagesConfig, ) from litellm.types.llms.anthropic import ( + ANTHROPIC_ADVISOR_TOOL_TYPE, ANTHROPIC_BETA_HEADER_VALUES, AnthropicMessagesRequest, ) @@ -220,7 +221,8 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): # Prevents Anthropic 400: advisor_tool_result in history requires advisor tool. _tools = anthropic_messages_optional_request_params.get("tools") or [] _has_advisor = any( - isinstance(t, dict) and t.get("type") == "advisor_20260301" for t in _tools + isinstance(t, dict) and t.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE + for t in _tools ) if not _has_advisor: messages = strip_advisor_blocks_from_messages(messages) # type: ignore[assignment] @@ -339,7 +341,10 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): tools = optional_params.get("tools") if tools: for tool in tools: - if isinstance(tool, dict) and tool.get("type") == "advisor_20260301": + if ( + isinstance(tool, dict) + and tool.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE + ): beta_values.add( ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value ) diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index b9e1ebd417..c76f27bdd6 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -126,6 +126,9 @@ class AnthropicToolSearchToolBM25(TypedDict, total=False): input_examples: Optional[List[Dict[str, Any]]] +ANTHROPIC_ADVISOR_TOOL_TYPE = "advisor_20260301" + + class AnthropicAdvisorTool(TypedDict, total=False): """Advisor tool — pairs a fast executor model with a high-intelligence advisor model.""" From f40995418bb923844bb2167d532ad2b2183937ba Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Sat, 11 Apr 2026 11:09:55 -0700 Subject: [PATCH 17/19] fix(types): annotate ANTHROPIC_ADVISOR_TOOL_TYPE as Literal to satisfy mypy --- litellm/types/llms/anthropic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index c76f27bdd6..e3f63d0574 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -126,7 +126,7 @@ class AnthropicToolSearchToolBM25(TypedDict, total=False): input_examples: Optional[List[Dict[str, Any]]] -ANTHROPIC_ADVISOR_TOOL_TYPE = "advisor_20260301" +ANTHROPIC_ADVISOR_TOOL_TYPE: Literal["advisor_20260301"] = "advisor_20260301" class AnthropicAdvisorTool(TypedDict, total=False): From 082f0afb835c33fe7d7a6a63fd1e9c2f55d39d2e Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Sat, 11 Apr 2026 11:09:59 -0700 Subject: [PATCH 18/19] refactor(anthropic): extract output_config validation to helper to fix ruff PLR0915 --- litellm/llms/anthropic/chat/transformation.py | 75 +++++++++++-------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index e78c72c13d..d7ce6a5f8d 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -987,11 +987,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): if mcp_servers: optional_params["mcp_servers"] = mcp_servers elif param == "tool_choice" or param == "parallel_tool_calls": - _tool_choice: Optional[AnthropicMessagesToolChoice] = ( - self._map_tool_choice( - tool_choice=non_default_params.get("tool_choice"), - parallel_tool_use=non_default_params.get("parallel_tool_calls"), - ) + _tool_choice: Optional[ + AnthropicMessagesToolChoice + ] = self._map_tool_choice( + tool_choice=non_default_params.get("tool_choice"), + parallel_tool_use=non_default_params.get("parallel_tool_calls"), ) if _tool_choice is not None: @@ -1089,9 +1089,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): self.map_openai_context_management_to_anthropic(value) ) if anthropic_context_management is not None: - optional_params["context_management"] = ( - anthropic_context_management - ) + optional_params[ + "context_management" + ] = anthropic_context_management elif param == "speed" and isinstance(value, str): # Pass through Anthropic-specific speed parameter for fast mode optional_params["speed"] = value @@ -1165,9 +1165,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): text=system_message_block["content"], ) if "cache_control" in system_message_block: - anthropic_system_message_content["cache_control"] = ( - system_message_block["cache_control"] - ) + anthropic_system_message_content[ + "cache_control" + ] = system_message_block["cache_control"] anthropic_system_message_list.append( anthropic_system_message_content ) @@ -1191,9 +1191,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) ) if "cache_control" in _content: - anthropic_system_message_content["cache_control"] = ( - _content["cache_control"] - ) + anthropic_system_message_content[ + "cache_control" + ] = _content["cache_control"] anthropic_system_message_list.append( anthropic_system_message_content @@ -1479,23 +1479,32 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): **optional_params, } - ## Handle output_config (Anthropic-specific parameter) - if "output_config" in optional_params: - output_config = optional_params.get("output_config") - if output_config and isinstance(output_config, dict): - effort = output_config.get("effort") - if effort and effort not in ["high", "medium", "low", "max"]: - raise ValueError( - f"Invalid effort value: {effort}. Must be one of: 'high', 'medium', 'low', 'max'" - ) - if effort == "max" and not self._is_opus_4_6_model(model): - raise ValueError( - f"effort='max' is only supported by Claude Opus 4.6. Got model: {model}" - ) - data["output_config"] = output_config + self._apply_output_config( + data=data, model=model, optional_params=optional_params + ) return data + def _apply_output_config( + self, data: dict, model: str, optional_params: dict + ) -> None: + """Validate and apply output_config to the request data.""" + if "output_config" not in optional_params: + return + output_config = optional_params.get("output_config") + if not output_config or not isinstance(output_config, dict): + return + effort = output_config.get("effort") + if effort and effort not in ["high", "medium", "low", "max"]: + raise ValueError( + f"Invalid effort value: {effort}. Must be one of: 'high', 'medium', 'low', 'max'" + ) + if effort == "max" and not self._is_opus_4_6_model(model): + raise ValueError( + f"effort='max' is only supported by Claude Opus 4.6. Got model: {model}" + ) + data["output_config"] = output_config + def _transform_response_for_json_mode( self, json_mode: Optional[bool], @@ -1516,7 +1525,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) return _message - def extract_response_content(self, completion_response: dict) -> Tuple[ + def extract_response_content( + self, completion_response: dict + ) -> Tuple[ str, Optional[List[Any]], Optional[ @@ -1810,9 +1821,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): code_interpreter_results = self._build_code_interpreter_results( tool_results, code_by_id, container_id ) - provider_specific_fields["code_interpreter_results"] = ( - code_interpreter_results - ) + provider_specific_fields[ + "code_interpreter_results" + ] = code_interpreter_results container = completion_response.get("container") if container is not None: From 011e087939fa14bd0cd7e2b9ea16fb292a0cb889 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Sat, 11 Apr 2026 11:33:29 -0700 Subject: [PATCH 19/19] fix(anthropic): guard against non-dict messages in strip_advisor_blocks_from_messages --- litellm/llms/anthropic/common_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 1205d1afbc..1a003727e9 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -650,7 +650,7 @@ def strip_advisor_blocks_from_messages(messages: List[Any]) -> List[Any]: control or on a follow-up turn. """ for message in messages: - if message.get("role") != "assistant": + if not isinstance(message, dict) or message.get("role") != "assistant": continue content = message.get("content") if not isinstance(content, list):