initial a2a-pack
This commit is contained in:
3
a2a_pack/serve/__init__.py
Normal file
3
a2a_pack/serve/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .asgi import build_app, serve
|
||||
|
||||
__all__ = ["build_app", "serve"]
|
||||
104
a2a_pack/serve/asgi.py
Normal file
104
a2a_pack/serve/asgi.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""HTTP adapter that turns any :class:`A2AAgent` into a service.
|
||||
|
||||
This is intentionally minimal: it covers the surface needed to plug into
|
||||
the wider A2A ecosystem and the platform's control plane.
|
||||
|
||||
Endpoints:
|
||||
GET /healthz -> liveness
|
||||
GET /.well-known/agent-card -> Agent Card JSON
|
||||
POST /invoke/{skill} -> invoke skill (JSON in, JSON out)
|
||||
|
||||
Auth: a single bearer token is read from the ``A2A_API_KEY`` env var. If set,
|
||||
all routes except ``/healthz`` and the card require ``Authorization: Bearer
|
||||
<key>``. The bearer token is materialized into the agent's declared
|
||||
``auth_model`` (best-effort: APIKeyAuth, otherwise NoAuth).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..agent import A2AAgent, SkillInputError, SkillNotFound
|
||||
from ..auth import APIKeyAuth, NoAuth
|
||||
from ..context import LocalRunContext, MissingScopes
|
||||
|
||||
|
||||
class _InvokeIn(BaseModel):
|
||||
arguments: dict[str, Any] = {}
|
||||
|
||||
|
||||
def build_app(agent: A2AAgent) -> FastAPI:
|
||||
"""Build a FastAPI app for the given agent instance."""
|
||||
app = FastAPI(title=type(agent).name, version=type(agent).version)
|
||||
api_key = os.environ.get("A2A_API_KEY")
|
||||
|
||||
def _build_auth(provided_key: str | None) -> Any:
|
||||
auth_cls = type(agent).auth_model
|
||||
if auth_cls is APIKeyAuth:
|
||||
return APIKeyAuth(api_key_id=provided_key or "anonymous")
|
||||
if auth_cls is NoAuth:
|
||||
return NoAuth()
|
||||
# Unknown auth model: try default-construct, else fail loudly.
|
||||
try:
|
||||
return auth_cls()
|
||||
except Exception as exc: # pragma: no cover - depends on user model
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"cannot materialize auth_model {auth_cls.__name__}: {exc}",
|
||||
) from exc
|
||||
|
||||
def _check_key(authorization: str | None) -> str | None:
|
||||
if api_key is None:
|
||||
return None
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(401, "missing bearer token")
|
||||
token = authorization.split(None, 1)[1].strip()
|
||||
if token != api_key:
|
||||
raise HTTPException(401, "invalid bearer token")
|
||||
return token
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, Any]:
|
||||
ok = await agent.health()
|
||||
return {"ok": ok, "agent": type(agent).name, "version": type(agent).version}
|
||||
|
||||
@app.get("/.well-known/agent-card")
|
||||
async def agent_card() -> dict[str, Any]:
|
||||
return agent.card().model_dump(mode="json")
|
||||
|
||||
@app.post("/invoke/{skill_name}")
|
||||
async def invoke(
|
||||
skill_name: str,
|
||||
body: _InvokeIn,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
token = _check_key(authorization)
|
||||
ctx: LocalRunContext[Any] = LocalRunContext(
|
||||
auth=_build_auth(token), task_id=f"http-{skill_name}"
|
||||
)
|
||||
try:
|
||||
result = await agent.invoke_json(skill_name, ctx, body.arguments)
|
||||
except SkillNotFound:
|
||||
raise HTTPException(404, f"unknown skill: {skill_name}")
|
||||
except SkillInputError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
except MissingScopes as exc:
|
||||
raise HTTPException(403, str(exc))
|
||||
return {
|
||||
"result": result,
|
||||
"events": [
|
||||
{"kind": e.kind, "payload": e.payload} for e in ctx.events
|
||||
],
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def serve(agent: A2AAgent, *, host: str = "0.0.0.0", port: int = 8000) -> None:
|
||||
"""Run the agent's HTTP server with uvicorn (blocking)."""
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(build_app(agent), host=host, port=port, log_level="info")
|
||||
Reference in New Issue
Block a user