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:
Peter Boers 2026-09-03 09:43:04 +02:00
parent 658f50663d
commit d88bdaa17c
No known key found for this signature in database
2 changed files with 99 additions and 9 deletions

View file

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

View file

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