diff --git a/docs/my-website/docs/completion/anthropic_advisor_tool.md b/docs/my-website/docs/completion/anthropic_advisor_tool.md new file mode 100644 index 0000000000..3cb87ffdf3 --- /dev/null +++ b/docs/my-website/docs/completion/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..b1ec952544 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -861,6 +861,7 @@ const sidebars = { ] }, "providers/anthropic", + "providers/anthropic_tool_search", "providers/aws_sagemaker", { type: "category", @@ -1232,6 +1233,7 @@ const learnSidebar = { "completion/web_fetch", "completion/computer_use", "guides/code_interpreter", + "completion/anthropic_advisor_tool", "completion/message_sanitization", ], }, 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: 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, 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/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 9a99f9efc8..d7ce6a5f8d 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, @@ -75,7 +76,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 @@ -508,6 +514,23 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): type="tool_search_tool_bm25_20251119", name=tool_name, ) + elif tool["type"] == ANTHROPIC_ADVISOR_TOOL_TYPE: + 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=ANTHROPIC_ADVISOR_TOOL_TYPE, + 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 +1334,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") == ANTHROPIC_ADVISOR_TOOL_TYPE: + self._ensure_beta_header( + headers, ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value + ) + break return headers def transform_request( @@ -1390,6 +1419,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") == ANTHROPIC_ADVISOR_TOOL_TYPE + 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( @@ -1440,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], diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 7d2d0a7496..1a003727e9 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 not isinstance(message, dict) or 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: 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/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 9b60a58260..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, ) @@ -21,6 +22,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 +210,23 @@ 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") == ANTHROPIC_ADVISOR_TOOL_TYPE + 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, @@ -324,6 +337,19 @@ 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") == ANTHROPIC_ADVISOR_TOOL_TYPE + ): + 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: diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index 37044c2b4f..e3f63d0574 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -126,6 +126,19 @@ class AnthropicToolSearchToolBM25(TypedDict, total=False): input_examples: Optional[List[Dict[str, Any]]] +ANTHROPIC_ADVISOR_TOOL_TYPE: Literal["advisor_20260301"] = "advisor_20260301" + + +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 +178,7 @@ AllAnthropicToolsValues = Union[ AnthropicMemoryTool, AnthropicToolSearchToolRegex, AnthropicToolSearchToolBM25, + AnthropicAdvisorTool, ] @@ -654,6 +668,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) 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 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..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 @@ -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 @@ -3367,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 @@ -3381,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 @@ -3399,3 +3404,200 @@ 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" + + +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", "") + + +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 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