Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions litellm/proxy/_experimental/mcp_server/faults/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
dcr_fault_detail,
render_token_fault,
)
from litellm.proxy._experimental.mcp_server.faults.traversal import iter_exception_tree
from litellm.proxy._experimental.mcp_server.faults.types import (
CallerRejected,
CredentialSource,
Expand All @@ -34,5 +35,6 @@
"classify_upstream_dcr_rejection",
"classify_upstream_token_rejection",
"dcr_fault_detail",
"iter_exception_tree",
"render_token_fault",
]
29 changes: 8 additions & 21 deletions litellm/proxy/_experimental/mcp_server/faults/list_outcomes.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
MCPServerListError,
MCPUpstreamAuthError,
)
from litellm.proxy._experimental.mcp_server.faults.traversal import iter_exception_tree

ListFaultCategory: TypeAlias = Literal[
"auth_required",
Expand Down Expand Up @@ -63,30 +64,16 @@ class AggregateToolListing(NamedTuple):


def _iter_upstream_responses(exc: BaseException) -> Iterator[httpx.Response]:
"""Yield every ``httpx.Response`` in the exception tree (``__cause__``/``__context__``/
ExceptionGroup members) in deliberate order, mirroring how upstream failures surface through the
MCP SDK's task groups. Explicit links come first: each node's ``raise ... from`` cause, then
group members in raise order, then the incidental ``__context__`` chain, so a response raised
while handling the real failure can never shadow one on the explicit causal chain. Consumers
apply their own predicate over the stream: selecting the first response and THEN testing it
would miss a causal auth response sitting behind an unrelated earlier one."""
seen: set[int] = set()
stack = [exc]
while stack:
current = stack.pop()
if id(current) in seen:
continue
seen.add(id(current))
"""Yield every ``httpx.Response`` in the exception tree, in the shared traversal's deliberate
order (explicit causes first, ExceptionGroup members in raise order, the incidental
``__context__`` chain last), so a response raised while handling the real failure can never
shadow one on the explicit causal chain. Consumers apply their own predicate over the stream:
selecting the first response and THEN testing it would miss a causal auth response sitting
behind an unrelated earlier one."""
for current in iter_exception_tree(exc):
response = getattr(current, "response", None)
if isinstance(response, httpx.Response):
yield response
if current.__context__ is not None:
stack.append(current.__context__)
exceptions = getattr(current, "exceptions", None)
if isinstance(exceptions, tuple):
stack.extend(reversed(exceptions))
if current.__cause__ is not None:
stack.append(current.__cause__)


def _find_upstream_response(exc: BaseException) -> httpx.Response | None:
Expand Down
35 changes: 35 additions & 0 deletions litellm/proxy/_experimental/mcp_server/faults/traversal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Shared exception-tree traversal for fault classification.

Failures cross the MCP SDK's anyio task groups wrapped in ``ExceptionGroup``s and chained through
``raise ... from`` causes, so every classifier that needs an exception buried in the tree (an
upstream ``httpx.Response``, a context-window overflow) has to walk the same shapes. One traversal
with one deliberate order keeps blame assignment consistent across classifiers: explicit links are
searched before incidental ones, so an exception raised while handling the real failure can never
shadow the failure itself.
"""

from __future__ import annotations

from collections.abc import Iterator


def iter_exception_tree(exc: BaseException) -> Iterator[BaseException]:
"""Yield ``exc`` and every exception reachable from it, explicit links first: each node's
``raise ... from`` cause subtree, then ``ExceptionGroup`` members in raise order, then the
incidental ``__context__`` chain last. Cycle-safe via identity tracking, and iterative so a
deep chain cannot overflow the interpreter stack."""
seen: set[int] = set()
stack = [exc]
while stack:
current = stack.pop()
if id(current) in seen:
continue
seen.add(id(current))
yield current
if current.__context__ is not None:
stack.append(current.__context__)
exceptions = getattr(current, "exceptions", None)
if isinstance(exceptions, tuple):
stack.extend(reversed(exceptions))
if current.__cause__ is not None:
stack.append(current.__cause__)
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,7 @@ def _extract_upstream_auth_failure(
) -> Optional[tuple[int, Optional[str]]]:
"""The upstream 401/403 and its ``WWW-Authenticate`` header from the exception tree, or ``None``.

Delegates to the shared traversal in ``faults.list_outcomes`` so every consumer (tool listing,
Delegates to the shared traversal in ``faults`` so every consumer (tool listing,
tool calls, the connect-time probe) selects the same response with the same deliberate order:
explicit ``raise ... from`` causes first, ExceptionGroup members in raise order, the incidental
``__context__`` chain last. A response raised while handling the real failure can therefore never
Expand Down
22 changes: 10 additions & 12 deletions litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from litellm._logging import verbose_logger
from litellm.exceptions import ContextWindowExceededError
from litellm.litellm_core_utils.exception_mapping_utils import ExceptionCheckers
from litellm.proxy._experimental.mcp_server.faults import iter_exception_tree
from litellm.proxy._experimental.mcp_server.utils import MCP_TOOL_PREFIX_SEPARATOR

if TYPE_CHECKING:
Expand All @@ -34,18 +35,15 @@ def __init__(self, embedding_model: str, stage: str, original_error: str):
)


def _is_context_window_error(error: Optional[BaseException], max_depth: int = 5) -> bool:
"""Detect a context-window overflow anywhere in an exception's cause chain."""
current = error
for _ in range(max_depth):
if current is None:
return False
if isinstance(current, ContextWindowExceededError):
return True
if ExceptionCheckers.is_error_str_context_window_exceeded(str(current)):
return True
current = current.__cause__ or current.__context__
return False
def _is_context_window_error(error: Optional[BaseException]) -> bool:
"""Detect a context-window overflow anywhere in an exception's tree."""
if error is None:
return False
return any(
isinstance(current, ContextWindowExceededError)
or ExceptionCheckers.is_error_str_context_window_exceeded(str(current))
for current in iter_exception_tree(error)
)


class SemanticMCPToolFilter:
Expand Down
4 changes: 2 additions & 2 deletions ruff-strict-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"limit": 4
},
"BLE001": {
"limit": 2903
"limit": 2902
},
"C401": {
"limit": 11
Expand Down Expand Up @@ -363,6 +363,6 @@
"limit": 105
},
"UP045": {
"limit": 18462
"limit": 18461
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Traversal contract for the shared exception-tree walk: the root is yielded first, explicit
links win (the ``raise ... from`` cause subtree, then ExceptionGroup members in raise order,
then the incidental ``__context__`` chain last), and adversarial shapes terminate."""

from litellm.proxy._experimental.mcp_server.faults import iter_exception_tree


def test_yields_the_root_itself_first():
exc = ValueError("root")
assert list(iter_exception_tree(exc)) == [exc]


def test_cause_subtree_is_exhausted_before_context():
deep = KeyError("deep")
cause = RuntimeError("cause")
cause.__cause__ = deep
context = OSError("context")
root = ValueError("root")
root.__cause__ = cause
root.__context__ = context
assert list(iter_exception_tree(root)) == [root, cause, deep, context]


def test_group_members_yield_in_raise_order_between_cause_and_context():
first = KeyError("first")
second = IndexError("second")
group = BaseExceptionGroup("group", [first, second])
cause = RuntimeError("cause")
context = OSError("context")
group.__cause__ = cause
group.__context__ = context
assert list(iter_exception_tree(group)) == [group, cause, first, second, context]


def test_terminates_on_a_cause_cycle():
a = ValueError("a")
b = RuntimeError("b")
a.__cause__ = b
b.__cause__ = a
assert list(iter_exception_tree(a)) == [a, b]


def test_node_reachable_as_both_cause_and_context_yields_once():
inner = KeyError("inner")
root = ValueError("root")
root.__cause__ = inner
root.__context__ = inner
assert list(iter_exception_tree(root)) == [root, inner]
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,36 @@ def test_extract_upstream_auth_failure_returns_none_for_non_auth():
assert _extract_upstream_auth_failure(RuntimeError("boom")) is None


def _auth_status_error(status_code: int, www_authenticate: str) -> httpx.HTTPStatusError:
response = httpx.Response(
status_code=status_code,
headers={"www-authenticate": www_authenticate},
request=httpx.Request("GET", "https://upstream/mcp"),
)
return httpx.HTTPStatusError(str(status_code), request=response.request, response=response)


def test_extract_upstream_auth_failure_finds_401_behind_cause_chain():
wrapper = RuntimeError("wrapped")
wrapper.__cause__ = _auth_status_error(401, "Bearer")
assert _extract_upstream_auth_failure(wrapper) == (401, "Bearer")


def test_extract_upstream_auth_failure_finds_401_behind_context_chain():
wrapper = RuntimeError("wrapped")
wrapper.__context__ = _auth_status_error(401, "Bearer")
assert _extract_upstream_auth_failure(wrapper) == (401, "Bearer")


def test_extract_upstream_auth_failure_prefers_causal_chain_over_context():
"""A 403 raised incidentally while handling the real 401 (surviving only as ``__context__``)
must not shadow the 401 on the explicit ``raise ... from`` chain."""
wrapper = RuntimeError("wrapped")
wrapper.__cause__ = _auth_status_error(401, "Bearer realm=real")
wrapper.__context__ = _auth_status_error(403, "Bearer realm=incidental")
assert _extract_upstream_auth_failure(wrapper) == (401, "Bearer realm=real")


@pytest.mark.asyncio
async def test_fetch_tools_from_passthrough_raises_on_upstream_401():
manager = MCPServerManager()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1913,6 +1913,34 @@ def test_is_context_window_error_detection_variants():
assert not _is_context_window_error(None)


def test_is_context_window_error_sees_through_trees_the_chain_walk_missed():
"""Overflow shapes the old single-path depth-5 chain walk could not reach: hidden in
``__context__`` behind a non-matching ``__cause__``, buried inside an anyio-style
``ExceptionGroup``, and chained deeper than five links."""
import litellm
from litellm.proxy._experimental.mcp_server.semantic_tool_filter import (
_is_context_window_error,
)

def _cwe() -> litellm.ContextWindowExceededError:
return litellm.ContextWindowExceededError(message="overflow", model="m", llm_provider="openai")

shadowed = ValueError("wrapper")
shadowed.__cause__ = TypeError("unrelated failure")
shadowed.__context__ = _cwe()
assert _is_context_window_error(shadowed)

grouped = BaseExceptionGroup("task group", [RuntimeError("sibling"), _cwe()])
assert _is_context_window_error(grouped)

deep: BaseException = _cwe()
for depth in range(6):
wrapper = ValueError(f"layer {depth}")
wrapper.__cause__ = deep
deep = wrapper
assert _is_context_window_error(deep)


def _make_keyword_embedding_router(recorded_inputs):
"""
Mock litellm Router whose embeddings are deterministic keyword one-hots:
Expand Down
2 changes: 1 addition & 1 deletion type-discipline-budget.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"LIT001": {
"limit": 23409
"limit": 23408
},
"LIT002": {
"limit": 27511
Expand Down
Loading