Merge pull request #25545 from BerriAI/litellm_ishaan_april10

Litellm ishaan april10
This commit is contained in:
ishaan-berri
2026-04-11 12:14:08 -07:00
committed by GitHub
14 changed files with 944 additions and 37 deletions
@@ -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 400700 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 3545% 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)
+2
View File
@@ -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",
],
},
+3 -3
View File
@@ -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:
@@ -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,
@@ -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"
+63 -15
View File
@@ -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],
+52 -4
View File
@@ -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:
@@ -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(
@@ -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:
+15
View File
@@ -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)
+5
View File
@@ -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)
@@ -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
@@ -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
@@ -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