fix(advisor): use model metadata for native advisor routing
Some checks are pending
Unit Tests: Proxy DB Operations / proxy-db (auth-checks, tests/proxy_unit_tests/test_auth_checks.py tests/proxy_unit_tests/test_user_api_key_auth.py, 20, 8) (push) Waiting to run
Unit Tests: Proxy DB Operations / proxy-db (key-generation, tests/proxy_unit_tests/test_key_generate_prisma.py, 30, 0) (push) Waiting to run
Unit Tests: Proxy DB Operations / proxy-db (remaining, tests/proxy_unit_tests --ignore=tests/proxy_unit_tests/test_key_generate_prisma.py --ignore=tests/proxy_unit_tests/test_auth_checks.py --ignore=tests/proxy_unit_tests/test_user_api_key_auth.py, 30, 8) (push) Waiting to run
Unit Tests: Security / security (push) Waiting to run

Replace hardcoded Opus checks with a model-capability flag and shared alias resolution, ensure pre-request hooks still run for intercepted /messages calls, and clean up advisor stream tracking on failure paths.

Made-with: Cursor
This commit is contained in:
Sameer Kankute 2026-04-16 20:12:22 +05:30
parent 2a198265ac
commit 54fb9da4b2
No known key found for this signature in database
11 changed files with 251 additions and 123 deletions

View file

@ -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]

View file

@ -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(

View file

@ -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.

View file

@ -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

View file

@ -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]

View file

@ -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,

View file

@ -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]

View file

@ -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:

View file

@ -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,

View file

@ -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(

View file

@ -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"