diff --git a/litellm/__init__.py b/litellm/__init__.py index 8087e3f5311..87230bf23c4 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1904,6 +1904,7 @@ if TYPE_CHECKING: supports_audio_output: Callable[..., bool] supports_system_messages: Callable[..., bool] supports_reasoning: Callable[..., bool] + supports_native_advisor_tool: Callable[..., bool] acreate: Callable[..., Any] get_max_tokens: Callable[..., int] get_model_info: Callable[..., _ModelInfoType] # type: ignore[no-redef] diff --git a/litellm/integrations/advisor_interception/handler.py b/litellm/integrations/advisor_interception/handler.py index 14b420dc0b9..a59dcee9d7e 100644 --- a/litellm/integrations/advisor_interception/handler.py +++ b/litellm/integrations/advisor_interception/handler.py @@ -23,6 +23,10 @@ from litellm.integrations.advisor_interception.tools import ( from litellm.integrations.custom_logger import CustomLogger from litellm.types.integrations.advisor_interception import AdvisorInterceptionConfig from litellm.types.utils import CallTypes, LlmProviders +from litellm.utils import ( + resolve_proxy_model_alias_to_litellm_model, + supports_native_advisor_tool, +) class AdvisorInterceptionLogger(CustomLogger): @@ -224,6 +228,18 @@ class AdvisorInterceptionLogger(CustomLogger): return self._wrap_as_streaming_if_needed(result) return result + async def async_log_failure_event( + self, kwargs, response_obj, start_time, end_time + ) -> None: + """ + Cleanup per-call advisor state on failure paths. + """ + call_id = kwargs.get("litellm_call_id") + if isinstance(call_id, str): + self._advisor_config_by_call_id.pop(call_id, None) + self._skip_post_hook_call_ids.discard(call_id) + self._converted_stream_call_ids.discard(call_id) + async def async_should_run_chat_completion_agentic_loop( self, response: Any, @@ -244,7 +260,7 @@ class AdvisorInterceptionLogger(CustomLogger): return False, {} # Only skip the orchestration loop for native providers when the advisor - # model is actually supported natively (Anthropic Claude Opus 4.6). + # model is actually supported natively by the provider. # For Anthropic executors with a non-native advisor model, fall through # to the orchestration loop below. if custom_llm_provider in ADVISOR_NATIVE_PROVIDERS: @@ -422,6 +438,8 @@ class AdvisorInterceptionLogger(CustomLogger): finally: if isinstance(call_id, str): self._advisor_config_by_call_id.pop(call_id, None) + self._skip_post_hook_call_ids.discard(call_id) + self._converted_stream_call_ids.discard(call_id) @staticmethod def _wrap_as_streaming_if_needed(response: Any) -> Any: @@ -456,33 +474,16 @@ class AdvisorInterceptionLogger(CustomLogger): @staticmethod def _is_native_anthropic_advisor_model(advisor_model: str) -> bool: """ - Return True only when the advisor model resolves to Anthropic Claude Opus 4.6, - which is the only model Anthropic supports as a native advisor. + Return True when the advisor model supports Anthropic native advisor. Handles bare model names, litellm provider-prefixed names (e.g. ``anthropic/claude-opus-4-6``) and proxy model aliases. """ - # Resolve proxy alias → underlying litellm model string first. - try: - llm_router = AdvisorInterceptionLogger._get_llm_router() - if llm_router is not None: - for deployment in llm_router.model_list or []: - if deployment.get("model_name") == advisor_model: - advisor_model = ( - deployment.get("litellm_params", {}).get("model") - or advisor_model - ) - break - except Exception: - pass - - normalized = advisor_model.lower().replace("_", "-") - # Must be an Anthropic model and specifically opus-4-6. - is_anthropic = normalized.startswith("anthropic/") or ( - "/" not in normalized and "claude" in normalized + resolved_model = resolve_proxy_model_alias_to_litellm_model(advisor_model) + model_to_check = resolved_model or advisor_model + return supports_native_advisor_tool( + model=model_to_check, custom_llm_provider="anthropic" ) - is_opus_46 = "claude-opus-4-6" in normalized or "claude-opus-4.6" in normalized - return is_anthropic and is_opus_46 @staticmethod async def _call_advisor_model( diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index 6ede735e392..d9ba92b696d 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -8,7 +8,7 @@ import asyncio import contextvars from functools import partial -from typing import Any, AsyncIterator, Coroutine, Dict, List, Optional, Union, cast +from typing import Any, AsyncIterator, Coroutine, Dict, List, Optional, Tuple, Type, Union, cast import litellm from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -57,6 +57,7 @@ async def _execute_pre_request_hooks( tools: Optional[List[Dict]], stream: Optional[bool], custom_llm_provider: Optional[str], + skip_callback_types: Optional[Tuple[Type[Any], ...]] = None, **kwargs, ) -> Dict: """ @@ -86,6 +87,7 @@ async def _execute_pre_request_hooks( # Build complete request kwargs dict request_kwargs = { + "messages": messages, "tools": tools, "stream": stream, "litellm_params": { @@ -102,6 +104,8 @@ async def _execute_pre_request_hooks( for callback in litellm.callbacks: if not isinstance(callback, _CustomLogger): continue + if skip_callback_types and isinstance(callback, skip_callback_types): + continue # Call the pre-request hook modified_kwargs = await callback.async_pre_request_hook( @@ -199,10 +203,43 @@ async def anthropic_messages( except Exception: pass + # Execute pre-request hooks first so user CustomLoggers can mutate request + # params even when a MessagesInterceptor handles the call. Skip only the + # advisor interception hook here so advisor tools remain visible. + from litellm.integrations.advisor_interception.handler import ( + AdvisorInterceptionLogger, + ) + + request_kwargs = await _execute_pre_request_hooks( + model=model, + messages=messages, + tools=tools, + stream=stream, + custom_llm_provider=custom_llm_provider, + skip_callback_types=(AdvisorInterceptionLogger,), + **kwargs, + ) + + # Extract modified parameters + messages = request_kwargs.pop("messages", messages) + tools = request_kwargs.pop("tools", tools) + stream = request_kwargs.pop("stream", stream) + # Propagate provider derived in pre-request hooks. + if not custom_llm_provider: + custom_llm_provider = request_kwargs.get("litellm_params", {}).get( + "custom_llm_provider" + ) + if not custom_llm_provider: + try: + _, custom_llm_provider, _, _ = litellm.get_llm_provider(model=model) + except Exception: + pass + request_kwargs.pop("litellm_params", None) + kwargs.update(request_kwargs) + # Run registered MessagesInterceptors (e.g. advisor orchestration loop) - # BEFORE pre-request hooks, because hooks like AdvisorInterceptionLogger - # would convert the advisor_20260301 tool into an OpenAI function tool, - # making it invisible to the Messages API interceptor. + # after non-advisor pre-request hooks have had a chance to mutate request + # data, while keeping advisor tools unconverted for visibility. for interceptor in get_messages_interceptors(): if interceptor.can_handle(tools, custom_llm_provider): return await interceptor.handle( @@ -217,34 +254,6 @@ async def anthropic_messages( **kwargs, ) - # Execute pre-request hooks to allow CustomLoggers to modify request - request_kwargs = await _execute_pre_request_hooks( - model=model, - messages=messages, - tools=tools, - stream=stream, - custom_llm_provider=custom_llm_provider, - **kwargs, - ) - - # Extract modified parameters - tools = request_kwargs.pop("tools", tools) - stream = request_kwargs.pop("stream", stream) - # Propagate the provider derived inside pre-request hooks, if not already set. - if not custom_llm_provider: - custom_llm_provider = request_kwargs.get("litellm_params", {}).get( - "custom_llm_provider" - ) - if not custom_llm_provider: - try: - _, custom_llm_provider, _, _ = litellm.get_llm_provider(model=model) - except Exception: - pass - # Remove litellm_params from kwargs (only needed for hooks) - request_kwargs.pop("litellm_params", None) - # Merge back any other modifications - kwargs.update(request_kwargs) - # Short-circuit web-search-only requests: detect the pattern, execute # search directly via Tavily/Perplexity, and return a synthetic response # without ever touching the backend LLM or the adapter path. diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py index 968e598a7fa..f2276bcd9c8 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py @@ -24,6 +24,10 @@ from litellm.types.llms.anthropic_messages.anthropic_response import ( AnthropicMessagesResponse, ) from litellm.types.llms.anthropic import ANTHROPIC_ADVISOR_TOOL_TYPE +from litellm.utils import ( + resolve_proxy_model_alias_to_litellm_model, + supports_native_advisor_tool, +) ADVISOR_MAX_USES: int = _c.ADVISOR_MAX_USES ADVISOR_TOOL_DESCRIPTION: str = _c.ADVISOR_TOOL_DESCRIPTION @@ -203,43 +207,13 @@ def _resolve_default_advisor_model() -> str: return params.get("default_advisor_model", "") or "" -def _is_anthropic_opus_46_model(model: str) -> bool: - """Return True for Anthropic Claude Opus 4.6 model identifiers.""" - normalized = model.lower().replace("_", "-") - return "anthropic/" in normalized and "claude-opus-4-6" in normalized - - -def _resolve_proxy_model_alias_to_litellm_model(model: str) -> str: - """ - Resolve a proxy ``model_name`` alias to its configured ``litellm_params.model``. - - Example: ``claude_opus`` -> ``anthropic/claude-opus-4-6``. - """ - try: - from litellm.proxy.proxy_server import llm_router - except Exception: - return "" - - model_list = getattr(llm_router, "model_list", None) or [] - for deployment in model_list: - if not isinstance(deployment, dict): - continue - if deployment.get("model_name") != model: - continue - litellm_params = deployment.get("litellm_params") or {} - configured_model = litellm_params.get("model") - if isinstance(configured_model, str): - return configured_model - return "" - - def _should_use_native_anthropic_advisor( tools: List[Dict], custom_llm_provider: Optional[str] ) -> bool: """ Use Anthropic's native advisor path only when: - executor provider is Anthropic, and - - advisor model resolves to Anthropic Claude Opus 4.6. + - advisor model supports the native advisor capability. """ if custom_llm_provider != "anthropic": return False @@ -255,12 +229,12 @@ def _should_use_native_anthropic_advisor( if not advisor_model: return False - if _is_anthropic_opus_46_model(advisor_model): - return True - # Proxy requests commonly pass advisor model as a model_name alias. - resolved_proxy_model = _resolve_proxy_model_alias_to_litellm_model(advisor_model) - if _is_anthropic_opus_46_model(resolved_proxy_model): + resolved_proxy_model = resolve_proxy_model_alias_to_litellm_model(advisor_model) + model_to_check = resolved_proxy_model or advisor_model + if supports_native_advisor_tool( + model=model_to_check, custom_llm_provider="anthropic" + ): return True try: @@ -269,8 +243,8 @@ def _should_use_native_anthropic_advisor( resolved_model, advisor_provider, _, _ = litellm.get_llm_provider( model=advisor_model ) - return advisor_provider == "anthropic" and _is_anthropic_opus_46_model( - resolved_model + return advisor_provider == "anthropic" and supports_native_advisor_tool( + model=resolved_model, custom_llm_provider=advisor_provider ) except Exception: return False diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 06d1cab0750..c0c57230f68 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -17,6 +17,7 @@ from litellm.types.llms.anthropic_messages.anthropic_response import ( ) from litellm.types.llms.anthropic_tool_search import get_tool_search_beta_header from litellm.types.router import GenericLiteLLMParams +from litellm.utils import resolve_proxy_model_alias_to_litellm_model from ...common_utils import ( AnthropicError, @@ -28,26 +29,6 @@ from ...common_utils import ( DEFAULT_ANTHROPIC_API_VERSION = "2023-06-01" -def _resolve_proxy_model_alias_to_litellm_model(model: str) -> str: - """Resolve proxy model_name alias to configured litellm model string.""" - try: - from litellm.proxy.proxy_server import llm_router - except Exception: - return "" - - model_list = getattr(llm_router, "model_list", None) or [] - for deployment in model_list: - if not isinstance(deployment, dict): - continue - if deployment.get("model_name") != model: - continue - litellm_params = deployment.get("litellm_params") or {} - configured_model = litellm_params.get("model") - if isinstance(configured_model, str): - return configured_model - return "" - - def _normalize_anthropic_advisor_tool_models(tools: List[Dict]) -> List[Dict]: """ Normalize advisor tool model names for Anthropic native /v1/messages calls. @@ -68,7 +49,7 @@ def _normalize_anthropic_advisor_tool_models(tools: List[Dict]) -> List[Dict]: updated_tool = dict(tool) advisor_model = updated_tool.get("model") if isinstance(advisor_model, str) and advisor_model.strip(): - resolved = _resolve_proxy_model_alias_to_litellm_model(advisor_model.strip()) + resolved = resolve_proxy_model_alias_to_litellm_model(advisor_model.strip()) canonical_model = resolved or advisor_model.strip() if canonical_model.startswith("anthropic/"): canonical_model = canonical_model.split("/", 1)[1] diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index c38693c7eba..72948a1f2fc 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -726,6 +726,7 @@ "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_native_advisor_tool": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -749,6 +750,7 @@ "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_native_advisor_tool": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, diff --git a/litellm/types/utils.py b/litellm/types/utils.py index cd5806b3ab7..6d820db4d15 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -137,6 +137,7 @@ class ProviderSpecificModelInfo(TypedDict, total=False): supports_parallel_function_calling: Optional[bool] supports_web_search: Optional[bool] supports_reasoning: Optional[bool] + supports_native_advisor_tool: Optional[bool] supports_url_context: Optional[bool] supports_none_reasoning_effort: Optional[bool] supports_xhigh_reasoning_effort: Optional[bool] diff --git a/litellm/utils.py b/litellm/utils.py index 8d55783bf9c..f0fa51a2bec 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -2734,6 +2734,43 @@ def supports_reasoning(model: str, custom_llm_provider: Optional[str] = None) -> ) +def resolve_proxy_model_alias_to_litellm_model(model: str) -> str: + """Resolve a proxy ``model_name`` alias to configured ``litellm_params.model``.""" + try: + from litellm.proxy.proxy_server import llm_router + except Exception: + return "" + + try: + model_list = llm_router.get_model_list(model_name=model) or [] + except Exception: + model_list = getattr(llm_router, "model_list", None) or [] + + for deployment in model_list: + if not isinstance(deployment, dict): + continue + litellm_params = deployment.get("litellm_params") or {} + configured_model = litellm_params.get("model") + if isinstance(configured_model, str): + return configured_model + return "" + + +def supports_native_advisor_tool( + model: str, custom_llm_provider: Optional[str] = None +) -> bool: + """ + Check if the given model supports Anthropic's native advisor tool. + """ + resolved_model = resolve_proxy_model_alias_to_litellm_model(model) + model_to_check = resolved_model or model + return _supports_factory( + model=model_to_check, + custom_llm_provider=custom_llm_provider, + key="supports_native_advisor_tool", + ) + + def supports_native_structured_output( model: str, custom_llm_provider: Optional[str] = None ) -> bool: diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 7e179db2a81..477799c66ce 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -8920,6 +8920,7 @@ "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_native_advisor_tool": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -8951,6 +8952,7 @@ "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_native_advisor_tool": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, diff --git a/tests/test_litellm/integrations/advisor_interception/test_advisor_interception_handler.py b/tests/test_litellm/integrations/advisor_interception/test_advisor_interception_handler.py index f9c84798b0e..c1484aebabe 100644 --- a/tests/test_litellm/integrations/advisor_interception/test_advisor_interception_handler.py +++ b/tests/test_litellm/integrations/advisor_interception/test_advisor_interception_handler.py @@ -59,11 +59,15 @@ async def test_pre_request_hook_anthropic_converts_standard_tool_to_native(): "litellm_params": {"custom_llm_provider": "anthropic"}, } - result = await logger.async_pre_request_hook( - model="claude-sonnet-4-6", - messages=[{"role": "user", "content": "Help"}], - kwargs=kwargs, - ) + with patch( + "litellm.integrations.advisor_interception.handler.supports_native_advisor_tool", + return_value=True, + ): + result = await logger.async_pre_request_hook( + model="claude-sonnet-4-6", + messages=[{"role": "user", "content": "Help"}], + kwargs=kwargs, + ) assert result is not None tool = result["tools"][0] assert tool["type"] == "advisor_20260301" @@ -551,6 +555,33 @@ def test_default_advisor_model_is_none_by_default(): assert logger.default_advisor_model is None +def test_is_native_anthropic_advisor_model_delegates_to_model_capability(monkeypatch): + monkeypatch.setattr( + "litellm.integrations.advisor_interception.handler.resolve_proxy_model_alias_to_litellm_model", + lambda model: "anthropic/new-native-advisor-model" + if model == "proxy_alias" + else "", + ) + monkeypatch.setattr( + "litellm.integrations.advisor_interception.handler.supports_native_advisor_tool", + lambda model, custom_llm_provider=None: ( + custom_llm_provider == "anthropic" + and model == "anthropic/new-native-advisor-model" + ), + ) + + assert ( + AdvisorInterceptionLogger._is_native_anthropic_advisor_model("proxy_alias") + is True + ) + assert ( + AdvisorInterceptionLogger._is_native_anthropic_advisor_model( + "anthropic/claude-opus-4-6" + ) + is False + ) + + def test_convert_tools_raises_when_no_advisor_model(): logger = AdvisorInterceptionLogger() kwargs = { @@ -604,6 +635,28 @@ async def test_run_agentic_loop_raises_when_no_advisor_model(): ) +@pytest.mark.asyncio +async def test_async_log_failure_event_cleans_up_call_tracking_sets(): + logger = AdvisorInterceptionLogger(enabled_providers=["openai"]) + logger._advisor_config_by_call_id["failed-call"] = { + "advisor_model": "claude-opus-4-6", + "max_uses": 2, + } + logger._converted_stream_call_ids.add("failed-call") + logger._skip_post_hook_call_ids.add("failed-call") + + await logger.async_log_failure_event( + kwargs={"litellm_call_id": "failed-call"}, + response_obj=Exception("boom"), + start_time=None, + end_time=None, + ) + + assert "failed-call" not in logger._advisor_config_by_call_id + assert "failed-call" not in logger._converted_stream_call_ids + assert "failed-call" not in logger._skip_post_hook_call_ids + + @pytest.mark.asyncio async def test_agentic_loop_uses_router_when_available(monkeypatch): logger = AdvisorInterceptionLogger( diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_advisor_integration.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_advisor_integration.py index 616d6e5e287..a0dd486a1b4 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_advisor_integration.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_advisor_integration.py @@ -12,6 +12,7 @@ from typing import Dict from unittest.mock import patch import pytest +import litellm ADVISOR_TOOL = { "type": "advisor_20260301", @@ -34,7 +35,9 @@ def _text_resp(text: str, model: str = "gpt-4o-mini") -> Dict: } -def _advisor_call_resp(question: str = "How do I approach this?", tool_id: str = "tid_01") -> Dict: +def _advisor_call_resp( + question: str = "How do I approach this?", tool_id: str = "tid_01" +) -> Dict: return { "id": "msg_int_test", "type": "message", @@ -44,7 +47,7 @@ def _advisor_call_resp(question: str = "How do I approach this?", tool_id: str = { "type": "tool_use", "id": tool_id, - "name": "advisor", + "name": "consult_advisor", "input": {"question": question}, } ], @@ -93,8 +96,9 @@ async def test_full_dispatch_interceptor_fires_and_loop_completes(): custom_llm_provider="openai", ) - # 3 internal calls: executor → advisor → executor-final - assert call_count == 3 + # 2 calls to _call_messages_handler: executor -> executor-final. + # Advisor subcall runs via _call_advisor_with_router (acompletion path). + assert call_count == 2 assert isinstance(result, dict) content = result.get("content", []) @@ -183,3 +187,66 @@ async def test_anthropic_provider_bypasses_interceptor(): content = result.get("content", []) if isinstance(result, dict) else [] text_blocks = [b for b in content if b.get("type") == "text"] assert any("Native anthropic" in b.get("text", "") for b in text_blocks) + + +@pytest.mark.asyncio +async def test_messages_interceptor_path_still_runs_non_advisor_pre_request_hooks(): + """ + Pre-request hooks should still run for intercepted /messages requests, while + advisor tool conversion must be skipped so interceptors can still see the + native advisor_20260301 tool. + """ + from litellm.integrations.advisor_interception.handler import ( + AdvisorInterceptionLogger, + ) + from litellm.integrations.custom_logger import CustomLogger + from litellm.llms.anthropic.experimental_pass_through.messages.handler import ( + anthropic_messages, + ) + + class _NonAdvisorHookLogger(CustomLogger): + async def async_pre_request_hook(self, model, messages, kwargs): + kwargs["metadata"] = {"from_non_advisor_hook": True} + return kwargs + + class _CaptureInterceptor: + def __init__(self): + self.seen_kwargs = {} + self.seen_tools = None + + def can_handle(self, tools, custom_llm_provider): + return True + + async def handle(self, **kwargs): + self.seen_kwargs = kwargs + self.seen_tools = kwargs.get("tools") + return _text_resp("interceptor handled request") + + capture_interceptor = _CaptureInterceptor() + original_callbacks = litellm.callbacks + try: + litellm.callbacks = [ + AdvisorInterceptionLogger(default_advisor_model="claude-opus-4-6"), + _NonAdvisorHookLogger(), + ] + with patch( + "litellm.llms.anthropic.experimental_pass_through.messages.handler.get_messages_interceptors", + return_value=[capture_interceptor], + ): + result = await anthropic_messages( + model="openai/gpt-4o-mini", + messages=MESSAGES, + tools=[ADVISOR_TOOL], + stream=False, + max_tokens=256, + custom_llm_provider="openai", + ) + finally: + litellm.callbacks = original_callbacks + + assert isinstance(result, dict) + assert capture_interceptor.seen_kwargs.get("metadata") == { + "from_non_advisor_hook": True + } + assert isinstance(capture_interceptor.seen_tools, list) + assert capture_interceptor.seen_tools[0].get("type") == "advisor_20260301"