From d88bdaa17ca786848e6bb48933e6773ccc2887b4 Mon Sep 17 00:00:00 2001 From: Peter Boers Date: Thu, 3 Sep 2026 09:43:04 +0200 Subject: [PATCH] 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) --- litellm/proxy/route_llm_request.py | 24 ++++-- .../proxy/test_route_a2a_models.py | 84 +++++++++++++++++++ 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index 3d0bd5e61c9..48fdf29c209 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -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) diff --git a/tests/test_litellm/proxy/test_route_a2a_models.py b/tests/test_litellm/proxy/test_route_a2a_models.py index 35308474949..8e5954cf5af 100644 --- a/tests/test_litellm/proxy/test_route_a2a_models.py +++ b/tests/test_litellm/proxy/test_route_a2a_models.py @@ -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"