mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
* fix(a2a): add per-agent a2a_protocol_version override for langgraph-api 0.15 cards Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(a2a): cover streamed 0.3 replies through the real client Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(a2a): accept new kwargs in create_a2a_client fake; add protocol-version integration tests The create_a2a_client fake in tests/agent_tests/test_a2a_agent.py listed a fixed signature, so asend_message's new protocol_version= kwarg raised TypeError in test_a2a_non_streaming and test_a2a_streaming. Accept and ignore extra kwargs with **_, matching the new _capture fake in test_main.py. Add tests/integration/compatibility cells for litellm_params.a2a_protocol_version against a real 2-worker proxy with Postgres and Redis and scripted A2A peers modeled on langgraph-api 0.15.4: pinned send/stream on all three routes, aliases, the 1.0 opt-out, unchanged genuine v1 agents, PATCH taking effect on both workers, persistence via /v1/agents, the localhost retry, invalid values, upstream errors, the dialect hint, and the completion bridge stripping the key. Merge base5f969982fc: 33 red, 7 green. Headba709d3de5: 40/40 green twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: shivam <shivam@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Mrinal Chanshetty <mrinal@berri.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""Scripted A2A agents shaped like the servers litellm proxies to, for the a2a_protocol_version cells.
|
|
|
|
The ``langgraph`` dialect reproduces langgraph-api 0.15.4 byte for byte where it matters: its card declares
|
|
``protocolBinding: "JSONRPC"`` with ``protocolVersion: "1.0"``, and every reply, whether the caller used the
|
|
0.3 ``message/send`` or the v1 ``SendMessage`` method, is in the 0.3 ``kind``-discriminated dialect (a v1
|
|
``SendMessage`` reply wraps the 0.3 task under ``result.task``, as langgraph-api does). ``v1`` is a genuine
|
|
A2A 1.0 agent that answers ``SendMessage`` and ``SendStreamingMessage`` in the proto JSON dialect and rejects
|
|
the 0.3 method names.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from collections.abc import Callable, Iterator, Mapping
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass, field
|
|
from typing import Final, Literal
|
|
|
|
from integration._support.client import Gateway
|
|
from integration._support.database import read_rows
|
|
from integration._support.wire import Reply, Request, Wire, wire_server
|
|
from pydantic import JsonValue
|
|
|
|
Dialect = Literal["langgraph", "v1"]
|
|
LEGACY_METHODS: Final = frozenset({"message/send", "message/stream"})
|
|
V1_METHODS: Final = frozenset({"SendMessage", "SendStreamingMessage"})
|
|
HINT: Final = (
|
|
"(the agent replied in the A2A 0.3 dialect while its card declares 1.0; "
|
|
'set litellm_params.a2a_protocol_version: "0.3" on this agent)'
|
|
)
|
|
KIND_ERROR: Final = 'has no field named "kind"'
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Peer:
|
|
"""One scripted agent: ``card`` is what the card endpoint serves, ``dialect`` how the POSTs answer."""
|
|
|
|
marker: str
|
|
dialect: Dialect
|
|
interfaces: list[dict[str, str]] | None = None
|
|
card_url: str | None = None
|
|
error: Mapping[str, JsonValue] | None = None
|
|
post_status: int = 200
|
|
card_status: int = 200
|
|
wire: Wire | None = None
|
|
extra_targets: frozenset[str] = field(default_factory=frozenset)
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
assert self.wire is not None
|
|
return self.wire.url + "/a2a/" + self.marker
|
|
|
|
def card(self) -> dict[str, JsonValue]:
|
|
interfaces: Final = self.interfaces if self.interfaces is not None else [{"protocolBinding": "JSONRPC"}]
|
|
return {
|
|
"name": self.marker,
|
|
"description": "Synthetic langgraph-api 0.15 echo assistant",
|
|
"url": self.card_url or self.url,
|
|
"supportedInterfaces": [
|
|
{
|
|
"url": interface.get("url", self.card_url or self.url),
|
|
"protocolBinding": interface["protocolBinding"],
|
|
"protocolVersion": interface.get("protocolVersion", "1.0"),
|
|
}
|
|
for interface in interfaces
|
|
],
|
|
"capabilities": {"streaming": True, "pushNotifications": False},
|
|
"defaultInputModes": ["application/json", "text/plain"],
|
|
"defaultOutputModes": ["application/json", "text/plain"],
|
|
"skills": [],
|
|
"version": "0.15.4",
|
|
}
|
|
|
|
def respond(self, request: Request) -> Reply:
|
|
if request.method == "GET":
|
|
if self.card_status != 200:
|
|
return Reply(status=self.card_status, body=b'{"detail": "no card here"}')
|
|
return Reply(body=json.dumps(self.card()).encode())
|
|
if self.post_status != 200:
|
|
return Reply(status=self.post_status, body=b'{"detail": "scripted failure"}')
|
|
body: Final = json.loads(request.body)
|
|
method: Final = body["method"]
|
|
if self.error is not None:
|
|
return _json({"jsonrpc": "2.0", "id": body["id"], "error": dict(self.error)})
|
|
if self.dialect == "v1" and method not in V1_METHODS:
|
|
return _json({"jsonrpc": "2.0", "id": body["id"], "error": {"code": -32601, "message": "Method not found"}})
|
|
text: Final = _input_text(body["params"]["message"])
|
|
reply: Final = "echo: " + text
|
|
if method in {"message/stream", "SendStreamingMessage"}:
|
|
frames: Final = (
|
|
self._status_frame(body["id"], reply, final=False),
|
|
self._artifact_frame(body["id"], reply),
|
|
self._status_frame(body["id"], None, final=True),
|
|
)
|
|
return Reply(
|
|
content_type="text/event-stream",
|
|
chunks=tuple(f"event: message\ndata: {json.dumps(frame)}\n\n".encode() for frame in frames),
|
|
)
|
|
task: Final = self._task(reply, text, v1=self.dialect == "v1")
|
|
result: Final = {"task": task} if method == "SendMessage" else task
|
|
return _json({"jsonrpc": "2.0", "id": body["id"], "result": result})
|
|
|
|
def _ids(self) -> tuple[str, str]:
|
|
return self.marker + "-task", self.marker + "-context"
|
|
|
|
def _task(self, reply: str, text: str, *, v1: bool) -> dict[str, JsonValue]:
|
|
task_id, context_id = self._ids()
|
|
if v1:
|
|
return {
|
|
"id": task_id,
|
|
"contextId": context_id,
|
|
"status": {"state": "TASK_STATE_COMPLETED"},
|
|
"artifacts": [{"artifactId": self.marker + "-artifact", "parts": [{"text": reply}]}],
|
|
"history": [{"role": "ROLE_USER", "messageId": self.marker + "-in", "parts": [{"text": text}]}],
|
|
}
|
|
return {
|
|
"kind": "task",
|
|
"id": task_id,
|
|
"contextId": context_id,
|
|
"status": {"state": "completed"},
|
|
"artifacts": [{"artifactId": self.marker + "-artifact", "parts": [{"kind": "text", "text": reply}]}],
|
|
"history": [
|
|
{
|
|
"kind": "message",
|
|
"role": "user",
|
|
"messageId": self.marker + "-in",
|
|
"parts": [{"kind": "text", "text": text}],
|
|
}
|
|
],
|
|
}
|
|
|
|
def _status_frame(self, request_id: JsonValue, reply: str | None, *, final: bool) -> dict[str, JsonValue]:
|
|
task_id, context_id = self._ids()
|
|
if self.dialect == "v1":
|
|
status: dict[str, JsonValue] = {"state": "TASK_STATE_COMPLETED" if final else "TASK_STATE_WORKING"}
|
|
if reply is not None:
|
|
status["message"] = {
|
|
"role": "ROLE_AGENT",
|
|
"messageId": self.marker + "-out",
|
|
"parts": [{"text": reply}],
|
|
}
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"result": {"statusUpdate": {"taskId": task_id, "contextId": context_id, "status": status}},
|
|
}
|
|
status = {"state": "completed" if final else "working"}
|
|
if reply is not None:
|
|
status["message"] = {
|
|
"kind": "message",
|
|
"role": "agent",
|
|
"messageId": self.marker + "-out",
|
|
"parts": [{"kind": "text", "text": reply}],
|
|
}
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"result": {
|
|
"kind": "status-update",
|
|
"taskId": task_id,
|
|
"contextId": context_id,
|
|
"status": status,
|
|
"final": final,
|
|
},
|
|
}
|
|
|
|
def _artifact_frame(self, request_id: JsonValue, reply: str) -> dict[str, JsonValue]:
|
|
task_id, context_id = self._ids()
|
|
if self.dialect == "v1":
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"result": {
|
|
"artifactUpdate": {
|
|
"taskId": task_id,
|
|
"contextId": context_id,
|
|
"artifact": {"artifactId": self.marker + "-artifact", "parts": [{"text": reply}]},
|
|
"lastChunk": True,
|
|
}
|
|
},
|
|
}
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"result": {
|
|
"kind": "artifact-update",
|
|
"taskId": task_id,
|
|
"contextId": context_id,
|
|
"artifact": {"artifactId": self.marker + "-artifact", "parts": [{"kind": "text", "text": reply}]},
|
|
"lastChunk": True,
|
|
},
|
|
}
|
|
|
|
|
|
def _json(value: Mapping[str, JsonValue]) -> Reply:
|
|
return Reply(body=json.dumps(value).encode())
|
|
|
|
|
|
def _input_text(message: Mapping[str, JsonValue]) -> str:
|
|
parts: Final = message["parts"]
|
|
assert isinstance(parts, list) and parts, message
|
|
first: Final = parts[0]
|
|
assert isinstance(first, dict), message
|
|
return str(first["text"])
|
|
|
|
|
|
@contextmanager
|
|
def peer(marker_prefix: str, dialect: Dialect, **fields: object) -> Iterator[Peer]:
|
|
agent: Final = Peer(marker=marker_prefix + uuid.uuid4().hex, dialect=dialect, **fields) # type: ignore[arg-type]
|
|
with wire_server(agent.respond) as wire:
|
|
agent.wire = wire
|
|
yield agent
|
|
|
|
|
|
def register(
|
|
gateway: Gateway,
|
|
cleanups: Callable[..., object],
|
|
name: str,
|
|
url: str,
|
|
litellm_params: Mapping[str, JsonValue] | None,
|
|
) -> str:
|
|
body: dict[str, JsonValue] = {
|
|
"agent_name": name,
|
|
"agent_card_params": {
|
|
"protocolVersion": "1.0",
|
|
"name": name,
|
|
"description": "Synthetic langgraph-api 0.15 echo assistant",
|
|
"version": "0.15.4",
|
|
"url": url,
|
|
"capabilities": {"streaming": True},
|
|
"defaultInputModes": ["text"],
|
|
"defaultOutputModes": ["text"],
|
|
"skills": [],
|
|
},
|
|
}
|
|
if litellm_params is not None:
|
|
body["litellm_params"] = dict(litellm_params)
|
|
created: Final = gateway.request("POST", "/v1/agents", body)
|
|
assert created.status_code == 200, created.text
|
|
identity: Final = str(created.json()["agent_id"])
|
|
|
|
def delete() -> None:
|
|
deleted: Final = gateway.request("DELETE", f"/v1/agents/{identity}")
|
|
assert deleted.status_code == 200, deleted.text
|
|
assert read_rows('SELECT agent_id FROM "LiteLLM_AgentsTable" WHERE agent_id=%s', (identity,)) == []
|
|
|
|
cleanups(delete)
|
|
return identity
|
|
|
|
|
|
def legacy_send(marker: str, text: str = "synthetic ping", method: str = "message/send") -> dict[str, JsonValue]:
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": marker,
|
|
"method": method,
|
|
"params": {
|
|
"message": {
|
|
"kind": "message",
|
|
"role": "user",
|
|
"messageId": marker + "-in",
|
|
"parts": [{"kind": "text", "text": text}],
|
|
}
|
|
},
|
|
}
|
|
|
|
|
|
def posts(wire: Wire) -> tuple[dict[str, JsonValue], ...]:
|
|
return tuple(json.loads(item.body) for item in wire.drain() if item.method == "POST")
|
|
|
|
|
|
def sse_frames(text: str) -> tuple[dict[str, JsonValue], ...]:
|
|
return tuple(
|
|
json.loads(line[len("data: ") :])
|
|
for line in text.splitlines()
|
|
if line.startswith("data: ") and line[6:].strip()
|
|
)
|
|
|
|
|
|
def spend_rows(request_id: str) -> list[dict[str, JsonValue]]:
|
|
return read_rows(
|
|
'SELECT request_id, model, call_type, status FROM "LiteLLM_SpendLogs" WHERE request_id=%s', (request_id,)
|
|
)
|