AgentEvalTool/backend/agenteval/channels/http.py

133 lines
5.1 KiB
Python

"""Generic HTTP evaluation channel.
Supports any REST API that follows a configurable request/response template.
Configuration keys (all under channel_config):
Required:
send_url URL to POST the message to (supports {message} template var)
reply_url URL to GET the reply from (supports {msg_id} template var)
Optional:
health_url URL for health check GET (defaults to send_url)
headers dict of extra request headers (e.g. Authorization)
send_body JSON body template for POST; {message} is substituted
Default: {"text": "{message}"}
msg_id_path dot-separated path to extract msg_id from send response
Default: "id"
reply_path dot-separated path to extract reply text from reply response
Default: "text"
reply_ready_path dot-separated path to a boolean indicating reply is ready
Default: (treat any 200 as ready)
poll_interval seconds between polls (default 1.0)
timeout seconds before poll gives up (default 30.0)
"""
import asyncio
import json
import uuid
from typing import Any, Optional
import httpx
from agenteval.channels.base import ChannelHealth, ChannelTransportError, EvalChannel, Reply, SendResult
def _get_path(data: Any, path: str) -> Any:
"""Traverse a dot-separated key path into a nested dict/list."""
parts = path.split(".")
current = data
for part in parts:
if current is None:
return None
if isinstance(current, dict):
current = current.get(part)
elif isinstance(current, list) and part.isdigit():
idx = int(part)
current = current[idx] if idx < len(current) else None
else:
return None
return current
class HttpChannel(EvalChannel):
"""Generic HTTP channel: POST to send, GET/POST to poll for reply."""
def __init__(self, config: dict[str, Any]):
self.send_url: str = config["send_url"]
self.reply_url: str = config.get("reply_url", "")
self.health_url: str = config.get("health_url", self.send_url)
self.headers: dict[str, str] = config.get("headers", {})
self.send_body_template: str = config.get("send_body", '{"text": "{message}"}')
self.msg_id_path: str = config.get("msg_id_path", "id")
self.reply_path: str = config.get("reply_path", "text")
self.reply_ready_path: Optional[str] = config.get("reply_ready_path")
self._poll_interval: float = float(config.get("poll_interval", 1.0))
self._client = httpx.AsyncClient(headers=self.headers, timeout=30)
async def close(self) -> None:
await self._client.aclose()
async def health_check(self) -> ChannelHealth:
try:
resp = await self._client.get(self.health_url)
resp.raise_for_status()
return ChannelHealth(ok=True, message=f"HTTP {resp.status_code}")
except Exception as exc:
return ChannelHealth(ok=False, message=str(exc))
async def _send(self, content: str, **kwargs: Any) -> SendResult:
body_str = self.send_body_template.replace("{message}", content)
try:
body = json.loads(body_str)
except json.JSONDecodeError:
body = {"text": content}
url = self.send_url.replace("{message}", content)
try:
resp = await self._client.post(url, json=body)
resp.raise_for_status()
data = resp.json()
msg_id = str(_get_path(data, self.msg_id_path) or uuid.uuid4())
return SendResult(ok=True, question_msg_id=msg_id, raw_response=data)
except (httpx.HTTPError, json.JSONDecodeError) as exc:
return SendResult(ok=False, error=str(exc))
async def _poll_reply(
self,
question_msg_id: str,
timeout: float = 30.0,
poll_interval: float = 1.0,
) -> Optional[Reply]:
interval = poll_interval or self._poll_interval
deadline = asyncio.get_event_loop().time() + timeout
url = self.reply_url.replace("{msg_id}", question_msg_id)
while asyncio.get_event_loop().time() < deadline:
try:
resp = await self._client.get(url)
resp.raise_for_status()
data = resp.json()
# Check if reply is ready (optional readiness flag)
if self.reply_ready_path:
ready = _get_path(data, self.reply_ready_path)
if not ready:
await asyncio.sleep(interval)
continue
reply_text = _get_path(data, self.reply_path)
if reply_text is not None:
raw = {"text": str(reply_text), "_raw": data}
return Reply(
question_msg_id=question_msg_id,
content=str(reply_text),
raw_message=raw,
)
except (httpx.HTTPError, json.JSONDecodeError) as exc:
raise ChannelTransportError(str(exc)) from exc
await asyncio.sleep(interval)
return None