mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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.
383 lines
13 KiB
Python
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)
|