133 lines
4.9 KiB
Python
133 lines
4.9 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, 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 Exception 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 Exception:
|
|
pass
|
|
|
|
await asyncio.sleep(interval)
|
|
|
|
return None
|