litellm/tests/e2e/a2a/a2a_client.py
Yuneng Jiang 09a98f5505
test(e2e): settle control-plane writes across every replica, not just one
The suite already waits for a new model or agent to become servable before
handing it back, but that wait returns on the first successful read. Every
request opens a fresh connection (e2e_http calls requests.* with no Session), so
a load-balanced Service routes each one independently: one successful read proves
one replica converged, and the caller's next request re-rolls and can land on a
replica that has not reloaded yet.

At replicaCount: 2 this surfaced as 30 failures on a SHA that is green at 1
replica -- 400 "Invalid model name passed", 404 "Guardrail not found", "no
healthy deployments for this model", and a /model/info listing that contained
one of two models created moments apart.

Add PROPAGATION_TIMEOUT (default 15s, override E2E_PROPAGATION_TIMEOUT) and
settle_propagation(), sized off the proxy's proxy_config_reload_interval_seconds
(30s by default, 7s on the e2e stack) plus margin, and settle after every
control-plane create whose object the suite then uses:

- ProxyClient.create_model and A2AClient.register_agent, after their existing
  polls -- the poll still fails loudly if the object never appears at all
- GuardrailsClient.register, which had no barrier; create_content_filter_guardrail
  and create_bedrock_guardrail now route through it instead of POSTing directly
- the guardrail creates in mcp_client and logging_client
- the vertex passthrough model, whose body cannot go through create_model

Left alone: the /model/new calls that assert a 403 or read back a status code,
since they never use the model.
2026-08-07 19:36:30 -07:00

383 lines
13 KiB
Python

"""Client for the proxy's A2A (agent-to-agent) surface.
An A2A agent is registered admin-side via POST /v1/agents with an agent card and
litellm_params; the proxy fronts it at /a2a/{id}, serving a proxy-owned agent card
at /.well-known/agent-card.json and accepting A2A JSON-RPC calls at /a2a/{id}. This
suite registers agents backed by the litellm_completion_bridge (custom_llm_provider
+ model), so message/send runs a real provider completion and comes back in the
agent's pinned A2A protocol version. The A2A request/response models are co-located
here because only this suite uses them.
"""
from __future__ import annotations
import time
import warnings
from dataclasses import dataclass
from pydantic import BaseModel, ConfigDict, Field
from e2e_config import settle_propagation
from e2e_http import NoBody, Result, Success, get_external, is_ok
from proxy_client import ProxyClient
class A2ACapabilities(BaseModel):
streaming: bool | None = None
push_notifications: bool | None = Field(default=None, serialization_alias="pushNotifications")
class A2ASkill(BaseModel):
id: str
name: str
description: str
tags: list[str]
examples: list[str] | None = None
class A2AProvider(BaseModel):
organization: str
url: str
class AgentCardParams(BaseModel):
"""The upstream agent card an admin registers. `protocolVersion` is the field the
proxy validates against SUPPORTED_A2A_PROTOCOL_VERSIONS on registration."""
protocol_version: str = Field(serialization_alias="protocolVersion")
name: str
description: str
version: str
url: str | None = None
capabilities: A2ACapabilities = A2ACapabilities()
skills: list[A2ASkill]
default_input_modes: list[str] = Field(default=["text"], serialization_alias="defaultInputModes")
default_output_modes: list[str] = Field(default=["text"], serialization_alias="defaultOutputModes")
preferred_transport: str | None = Field(default=None, serialization_alias="preferredTransport")
class UpstreamAgentCard(BaseModel):
"""A real published agent card parsed from a public /.well-known endpoint. Keys on
the A2A wire aliases so `model_validate_json` reads the served JSON and
`model_dump(by_alias=True)` re-emits it unchanged for verbatim registration; it is
only ever fetched-and-validated, never hand-constructed, so aliasing on the wire
names does not affect any call site."""
model_config = ConfigDict(populate_by_name=True)
protocol_version: str = Field(alias="protocolVersion")
name: str
description: str
version: str
url: str
provider: A2AProvider | None = None
documentation_url: str | None = Field(default=None, alias="documentationUrl")
capabilities: A2ACapabilities = A2ACapabilities()
skills: list[A2ASkill]
default_input_modes: list[str] = Field(default=["text"], alias="defaultInputModes")
default_output_modes: list[str] = Field(default=["text"], alias="defaultOutputModes")
preferred_transport: str | None = Field(default=None, alias="preferredTransport")
class A2ABridgeParams(BaseModel):
"""litellm_params that route the agent through the completion bridge: an A2A
message/send is transformed into a litellm.acompletion against this provider."""
model_config = ConfigDict(protected_namespaces=())
custom_llm_provider: str
model: str
api_key: str | None = None
class AgentRegisterBody(BaseModel):
agent_name: str
agent_card_params: AgentCardParams | UpstreamAgentCard
litellm_params: A2ABridgeParams | None = None
class A2ASecurityScheme(BaseModel):
type: str
scheme: str
class A2AInterface(BaseModel):
model_config = ConfigDict(populate_by_name=True)
url: str
protocol_version: str | None = Field(default=None, alias="protocolVersion")
class ServedAgentCard(BaseModel):
"""The proxy-owned card, either nested under a registration response's
`agent_card_params` or served raw at /.well-known/agent-card.json. The proxy
rewrites `url`/`supportedInterfaces` to itself and replaces the security scheme
with its own virtual-key bearer scheme."""
model_config = ConfigDict(populate_by_name=True)
protocol_version: str = Field(alias="protocolVersion")
name: str
url: str | None = None
security_schemes: dict[str, A2ASecurityScheme] | None = Field(default=None, alias="securitySchemes")
security: list[dict[str, list[str]]] | None = None
supported_interfaces: list[A2AInterface] | None = Field(default=None, alias="supportedInterfaces")
class AgentResponse(BaseModel):
agent_id: str
agent_name: str
agent_card_params: ServedAgentCard
class A2ATextPart(BaseModel):
kind: str = "text"
text: str
class A2ASearchPropertiesParams(BaseModel):
"""The strict param schema of the published property agent's `search_properties`
skill (unknown keys are rejected upstream), so a natural-language query like
"properties for sale in SF under $2M" is expressed as typed fields."""
un_locode: str | None = None
service_type: str | None = None
property_type: str | None = None
bedrooms_min: int | None = None
asking_price_max: float | None = None
limit: int | None = None
class A2ASkillInvocation(BaseModel):
skill: str
params: A2ASearchPropertiesParams
class A2ADataPart(BaseModel):
kind: str = "data"
data: A2ASkillInvocation
class A2AOutboundMessage(BaseModel):
role: str = "user"
parts: list[A2ATextPart | A2ADataPart]
message_id: str = Field(serialization_alias="messageId")
class A2AMessageSendParams(BaseModel):
message: A2AOutboundMessage
class A2AJsonRpcRequest(BaseModel):
jsonrpc: str = "2.0"
id: str
method: str = "message/send"
params: A2AMessageSendParams
class A2AResponsePart(BaseModel):
kind: str | None = None
text: str | None = None
class A2AResponseMessage(BaseModel):
model_config = ConfigDict(populate_by_name=True)
message_id: str | None = Field(default=None, alias="messageId")
role: str | None = None
parts: list[A2AResponsePart] = []
class A2ATaskStatus(BaseModel):
state: str | None = None
message: A2AResponseMessage | None = None
class A2AListingLocation(BaseModel):
"""Only the location fields a test reads back off a returned listing."""
un_locode: str | None = None
class A2AListing(BaseModel):
"""A single property card from the agent's `search_results` artifact; only the
identity/location fields a test asserts on are modelled."""
raia_id: str
property_type: str | None = None
service_type: str | None = None
location: A2AListingLocation = A2AListingLocation()
class A2ASearchResults(BaseModel):
"""The DataPart payload the property agent's `search_properties` skill returns:
the run count plus the listing cards themselves. Proof the tool actually ran and
matched, not just that the task completed with some text."""
total: int
count: int
listings: list[A2AListing] = []
class A2AArtifactPart(BaseModel):
kind: str | None = None
data: A2ASearchResults | None = None
class A2AArtifact(BaseModel):
model_config = ConfigDict(populate_by_name=True)
artifact_id: str | None = Field(default=None, alias="artifactId")
name: str | None = None
parts: list[A2AArtifactPart] = []
class A2AResult(BaseModel):
"""A message/send result. In 0.3 the message fields sit directly on the result
(`kind`/`role`/`parts`); in 1.0 they are nested under `message`; a real agent that
runs a task replies with a `task` whose agent text lives on `status.message` and
whose tool output lives on `artifacts`. `text` reads the agent's reply from
whichever shape the served version produced; `search_results` reads the tool's
structured output when the agent ran a skill."""
model_config = ConfigDict(populate_by_name=True)
kind: str | None = None
role: str | None = None
message_id: str | None = Field(default=None, alias="messageId")
parts: list[A2AResponsePart] = []
message: A2AResponseMessage | None = None
status: A2ATaskStatus | None = None
artifacts: list[A2AArtifact] = []
@property
def text(self) -> str:
if self.message is not None:
parts = self.message.parts
elif self.parts:
parts = self.parts
elif self.status is not None and self.status.message is not None:
parts = self.status.message.parts
else:
parts = []
return "".join(part.text or "" for part in parts)
@property
def is_nested_v1_shape(self) -> bool:
return self.message is not None
@property
def search_results(self) -> A2ASearchResults | None:
for artifact in self.artifacts:
for part in artifact.parts:
if part.data is not None:
return part.data
return None
class A2AError(BaseModel):
code: int
message: str
class A2AResponse(BaseModel):
jsonrpc: str
id: str | None = None
result: A2AResult | None = None
error: A2AError | None = None
@dataclass(frozen=True, slots=True)
class A2AClient:
proxy: ProxyClient
def register_agent(self, body: AgentRegisterBody) -> Result[AgentResponse]:
"""Register an agent and, on success, wait until the data plane serves it.
/v1/agents is a control-plane route; the /a2a/{agent_id} routes that serve
the card and run message/send are data plane, and only see the agent after
the next DB reload. A card read or message/send issued the instant this
returns can therefore 404 on the agent it just created. Waiting here keeps
every caller from having to poll, the same way ProxyClient.create_model
waits for a new model to become servable -- including the settle that
covers the other replicas, since one successful card read only proves the
replica that answered it has the agent.
"""
result = self.proxy.transport.post(
"/v1/agents",
headers=self.proxy.transport.master,
json=body,
response_type=AgentResponse,
)
if isinstance(result, Success):
written_at = time.monotonic()
self._await_agent_servable(result.data.agent_id)
settle_propagation(written_at)
return result
def _await_agent_servable(self, agent_id: str) -> None:
"""Block until the data plane serves `agent_id`'s card, or fail loudly at
poll_timeout (a real propagation problem, surfaced here rather than as a
downstream 404 on whichever /a2a call the test happened to make first)."""
deadline = time.monotonic() + self.proxy.poll_timeout
while True:
result = self.proxy.transport.get(
f"/a2a/{agent_id}/.well-known/agent-card.json",
headers=self.proxy.transport.master,
params=NoBody(),
response_type=ServedAgentCard,
)
if isinstance(result, Success):
return
if time.monotonic() >= deadline:
raise AssertionError(
f"agent {agent_id!r} was registered but never became servable on the "
f"data plane within {self.proxy.poll_timeout}s of POST /v1/agents "
f"(control/data-plane propagation issue); last card read: {result}"
)
time.sleep(self.proxy.poll_interval)
def get_agent(self, agent_id: str) -> Result[AgentResponse]:
return self.proxy.transport.get(
f"/v1/agents/{agent_id}",
headers=self.proxy.transport.master,
params=NoBody(),
response_type=AgentResponse,
)
def delete_agent(self, agent_id: str) -> None:
result = self.proxy.transport.delete(
f"/v1/agents/{agent_id}",
headers=self.proxy.transport.master,
json=NoBody(),
response_type=NoBody,
)
if not is_ok(result):
warnings.warn(f"delete_agent({agent_id!r}) failed: {result}", stacklevel=2)
def agent_card(self, agent_id: str, key: str) -> Result[ServedAgentCard]:
return self.proxy.transport.get(
f"/a2a/{agent_id}/.well-known/agent-card.json",
headers=self.proxy.transport.bearer(key),
params=NoBody(),
response_type=ServedAgentCard,
)
def send_message(self, agent_id: str, key: str, body: A2AJsonRpcRequest) -> Result[A2AResponse]:
return self.proxy.transport.post(
f"/a2a/{agent_id}",
headers=self.proxy.transport.bearer(key),
json=body,
response_type=A2AResponse,
)
def build_a2a_client(proxy: ProxyClient) -> A2AClient:
return A2AClient(proxy=proxy)
def fetch_agent_card(url: str, *, timeout: float = 20.0) -> Result[UpstreamAgentCard]:
"""Fetch a live A2A agent card from its /.well-known endpoint and parse it into the
registration model, so a test can register a real published card verbatim rather
than a hand-rolled one."""
return get_external(url, response_type=UpstreamAgentCard, timeout=timeout)