mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
fix(proxy): resolve a2a/ models before router branches can claim them
`_is_a2a_agent_model()` was the last `elif` in the routing chain, so two earlier branches shadowed it and the request failed with "no healthy deployments" instead of reaching the agent: - `map_team_model()` claims the request for any team-scoped virtual key, so A2A agents were unreachable for every non-admin key. A key with no team works; the same key attached to a team does not. - the wildcard/default-deployment fallback claims it whenever a pattern model group is configured (fixes #37581). An `a2a/` prefix is unambiguous and is never backed by a router deployment, so resolve it before any router-based branch. The existing test mocked `map_team_model` to None and `pattern_router` to an empty pattern list, which is exactly why neither case was caught; the new parametrized test exercises both shadowing branches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
658f50663d
commit
d88bdaa17c
2 changed files with 99 additions and 9 deletions
|
|
@ -536,6 +536,21 @@ async def _route_request_single_attempt( # noqa: ANN202 # returns unawaited pr
|
|||
return getattr(litellm, f"{route_type}")(**data)
|
||||
elif llm_router is not None:
|
||||
_raise_if_model_fully_blocked(llm_router=llm_router, model_name=data.get("model"), team_id=team_id)
|
||||
|
||||
# A2A agents are addressed by an unambiguous "a2a/" model prefix and are never backed
|
||||
# by a router deployment, so they must be resolved before any router-based branch.
|
||||
# Otherwise the branches below swallow the request and it fails with
|
||||
# "no healthy deployments": `map_team_model` claims it for team-scoped keys, and the
|
||||
# wildcard/default-deployment fallback claims it whenever a pattern model group exists.
|
||||
if _is_a2a_agent_model(data.get("model", "")):
|
||||
from litellm.proxy.agent_endpoints.a2a_routing import (
|
||||
route_a2a_agent_request,
|
||||
)
|
||||
|
||||
a2a_result: Final = await route_a2a_agent_request(data, route_type, user_api_key_dict=user_api_key_dict)
|
||||
if a2a_result is not None:
|
||||
return a2a_result
|
||||
|
||||
# Evals API: always route to litellm directly (not through router)
|
||||
# But extract model credentials if a model is provided
|
||||
if route_type in [
|
||||
|
|
@ -696,15 +711,6 @@ async def _route_request_single_attempt( # noqa: ANN202 # returns unawaited pr
|
|||
except Exception:
|
||||
# If router fails (e.g., model not found in router), fall back to direct call
|
||||
return getattr(litellm, f"{route_type}")(**data)
|
||||
elif _is_a2a_agent_model(data.get("model", "")):
|
||||
from litellm.proxy.agent_endpoints.a2a_routing import (
|
||||
route_a2a_agent_request,
|
||||
)
|
||||
|
||||
result: Final = await route_a2a_agent_request(data, route_type, user_api_key_dict=user_api_key_dict)
|
||||
if result is not None:
|
||||
return result
|
||||
# Fall through to raise exception below if result is None
|
||||
|
||||
elif user_model is not None or route_type == "allm_passthrough_route":
|
||||
return getattr(litellm, f"{route_type}")(**data)
|
||||
|
|
|
|||
|
|
@ -180,3 +180,87 @@ async def test_route_a2a_model_read_through_recovers_agent_created_on_sibling_re
|
|||
assert call_kwargs["model"] == f"a2a/{agent_name}"
|
||||
assert call_kwargs["api_base"] == "http://sibling-db-agent.example.com"
|
||||
prisma_client.db.litellm_agentstable.find_unique.assert_awaited()
|
||||
|
||||
|
||||
def _router_without_a2a_deployment(
|
||||
*,
|
||||
team_model: str | None = None,
|
||||
patterns: tuple[str, ...] = (),
|
||||
default_deployment: dict | None = None,
|
||||
) -> Mock:
|
||||
"""A router that serves no A2A deployment, optionally tripping one shadowing branch."""
|
||||
router = Mock()
|
||||
router.model_names = ["gpt-4", "gpt-3.5-turbo"]
|
||||
router.deployment_names = []
|
||||
router.has_model_id = Mock(return_value=False)
|
||||
router.is_recognized_model = Mock(return_value=False)
|
||||
router.get_routing_group = Mock(return_value=None)
|
||||
router.model_group_alias = None
|
||||
router.router_general_settings = Mock(pass_through_all_models=False)
|
||||
router.default_deployment = default_deployment
|
||||
router.pattern_router = Mock(patterns=list(patterns))
|
||||
router.map_team_model = Mock(return_value=team_model)
|
||||
return router
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"router_kwargs, extra_data",
|
||||
[
|
||||
pytest.param({}, {}, id="no_shadowing_branch"),
|
||||
pytest.param(
|
||||
{"team_model": "a2a/test-agent"},
|
||||
{"metadata": {"user_api_key_team_id": "team-123"}},
|
||||
id="team_scoped_key",
|
||||
),
|
||||
pytest.param({"patterns": ("openrouter/*",)}, {}, id="wildcard_model_group"),
|
||||
pytest.param(
|
||||
{"default_deployment": {"model_name": "*"}}, {}, id="default_deployment"
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_a2a_model_resolves_before_router_branches(router_kwargs, extra_data):
|
||||
"""
|
||||
Regression: an `a2a/` model must reach A2A routing even when a router branch would
|
||||
otherwise claim it.
|
||||
|
||||
`map_team_model` claims the request for any team-scoped key, and the
|
||||
wildcard/default-deployment fallback claims it whenever a pattern model group exists.
|
||||
Both previously shadowed the A2A branch, so the call failed with
|
||||
"no healthy deployments" instead of reaching the agent.
|
||||
"""
|
||||
from litellm.types.agents import AgentResponse
|
||||
|
||||
data = {
|
||||
"model": "a2a/test-agent",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
**extra_data,
|
||||
}
|
||||
|
||||
mock_agent = AgentResponse(
|
||||
agent_id="test-agent-id",
|
||||
agent_name="test-agent",
|
||||
agent_card_params={"url": "http://agent.example.com"},
|
||||
litellm_params=None,
|
||||
)
|
||||
mock_registry = Mock()
|
||||
mock_registry.get_agent_by_id = Mock(return_value=None)
|
||||
mock_registry.get_agent_by_name = Mock(return_value=mock_agent)
|
||||
|
||||
mock_acompletion = AsyncMock(return_value={"id": "test-response"})
|
||||
|
||||
with patch("litellm.acompletion", mock_acompletion), patch(
|
||||
"litellm.proxy.agent_endpoints.agent_registry.global_agent_registry",
|
||||
mock_registry,
|
||||
):
|
||||
await route_request(
|
||||
data=data,
|
||||
llm_router=_router_without_a2a_deployment(**router_kwargs),
|
||||
user_model=None,
|
||||
route_type="acompletion",
|
||||
)
|
||||
|
||||
mock_acompletion.assert_called_once()
|
||||
call_kwargs = mock_acompletion.call_args.kwargs
|
||||
assert call_kwargs["model"] == "a2a/test-agent"
|
||||
assert call_kwargs["api_base"] == "http://agent.example.com"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue