"""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