"""Async client for the LFX flow runtime (stateless `lfx serve` process).""" from __future__ import annotations from dataclasses import dataclass import httpx class LfxError(Exception): """Base error for LFX communication problems.""" class LfxNotFound(LfxError): """Flow id unknown to LFX (HTTP 404).""" class LfxInvalidFlow(LfxError): """Flow JSON rejected by LFX (HTTP 422).""" class LfxConflict(LfxError): """Flow id already registered without replace=true (HTTP 409).""" class LfxRunFailed(LfxError): """Flow execution failed inside LFX (HTTP 500 or success=false).""" class LfxUnavailable(LfxError): """LFX process unreachable.""" @dataclass class LfxUploadResult: id: str name: str description: str | None run_url: str @dataclass class LfxRunResult: result: str success: bool logs: str type: str component: str class LfxClient: """Thin async wrapper around the LFX serve HTTP API.""" def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None: self.base_url = base_url.rstrip("/") self.timeout = timeout self.api_key = api_key def _headers(self) -> dict[str, str]: return {"x-api-key": self.api_key} if self.api_key else {} def _client(self) -> httpx.AsyncClient: return httpx.AsyncClient( base_url=self.base_url, timeout=self.timeout, headers=self._headers() ) async def health(self) -> dict: try: async with self._client() as client: response = await client.get("/health") response.raise_for_status() return response.json() except httpx.HTTPError as exc: raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc async def list_flows(self) -> list[dict]: try: async with self._client() as client: response = await client.get("/flows") response.raise_for_status() return response.json() except httpx.HTTPStatusError as exc: raise self._mapped(exc) from exc except httpx.HTTPError as exc: raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc async def upload_flow( self, flow_json: dict, *, flow_id: str | None = None, replace: bool = False ) -> LfxUploadResult: """Register a Langflow-export-format flow JSON. Returns the LFX flow id.""" data = flow_json.get("data") if not isinstance(data, dict): raise LfxInvalidFlow("flow JSON must contain a 'data' object") body = { "name": flow_json.get("name", "flow"), "description": flow_json.get("description"), "data": data, "replace": replace, } if flow_id is not None: body["id"] = flow_id try: async with self._client() as client: response = await client.post("/flows/upload/", json=body) response.raise_for_status() payload = response.json() return LfxUploadResult( id=payload["id"], name=payload.get("name", ""), description=payload.get("description"), run_url=payload.get("run_url", ""), ) except httpx.HTTPStatusError as exc: raise self._mapped(exc) from exc except httpx.HTTPError as exc: raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc async def run_flow( self, lfx_flow_id: str, *, input_value: str, session_id: str | None = None, global_vars: dict[str, str] | None = None, ) -> LfxRunResult: """Execute a flow synchronously; raises on failure.""" body: dict = {"input_value": input_value} if session_id is not None: body["session_id"] = session_id if global_vars is not None: body["global_vars"] = global_vars try: async with self._client() as client: response = await client.post(f"/flows/{lfx_flow_id}/run", json=body) if response.status_code == 404: raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX") data = response.json() result = LfxRunResult( result=data.get("result", ""), success=bool(data.get("success", False)), logs=data.get("logs", ""), type=data.get("type", ""), component=data.get("component", ""), ) if response.status_code >= 400 or not result.success: raise LfxRunFailed(f"LFX run failed: {result.result}") return result except httpx.HTTPError as exc: raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc async def flow_info(self, lfx_flow_id: str) -> dict: try: async with self._client() as client: response = await client.get(f"/flows/{lfx_flow_id}/info") if response.status_code == 404: raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX") response.raise_for_status() return response.json() except httpx.HTTPError as exc: raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc async def delete_flow(self, lfx_flow_id: str) -> None: try: async with self._client() as client: response = await client.delete(f"/flows/{lfx_flow_id}") if response.status_code == 404: raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX") response.raise_for_status() except httpx.HTTPError as exc: raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc @staticmethod def _mapped(exc: httpx.HTTPStatusError) -> LfxError: status = exc.response.status_code detail = exc.response.text[:500] if status == 404: return LfxNotFound(detail) if status == 409: return LfxConflict(detail) if status == 422: return LfxInvalidFlow(detail) return LfxError(f"LFX error {status}: {detail}")