kirche-einfach/backend/app/flows/lfx_client.py

182 lines
6.3 KiB
Python

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