feat: Langflow authoring pipeline with tests

- LangflowClient (list/export, x-api-key auth, error mapping)
- import_from_langflow: export -> versioned definition -> LFX deploy
- POST /api/flows/import, GET /api/flows/langflow/available
- scripts/deploy_flow.py CLI
- 25 tests green (Langflow faked via dependency override + respx
  client tests; respx passthrough proven unreliable for LFX traffic)
This commit is contained in:
Jonas Miederer 2026-09-06 23:01:47 +00:00
parent 96c0da33c7
commit 1034206870
8 changed files with 451 additions and 0 deletions

View file

@ -15,6 +15,13 @@ from app.db import get_engine, get_session
from app.settings import settings
from . import runner, store
from .deploy import import_from_langflow
from .langflow_client import (
LangflowClient,
LangflowError,
LangflowNotFound,
LangflowUnavailable,
)
from .lfx_client import (
LfxClient,
LfxConflict,
@ -29,6 +36,8 @@ from .schemas import (
ApprovalRead,
FlowDefinitionCreate,
FlowDefinitionRead,
FlowImportRead,
FlowImportRequest,
FlowVersionCreate,
FlowVersionRead,
RunRead,
@ -47,6 +56,12 @@ def get_lfx() -> LfxClient:
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
def get_langflow() -> LangflowClient:
return LangflowClient(
settings.langflow_url, api_key=settings.langflow_api_key or ""
)
def _read_definition(d) -> FlowDefinitionRead:
return FlowDefinitionRead(
id=d.id,
@ -282,6 +297,58 @@ def list_runs(flow_id: UUID, session: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail=str(exc)) from exc
@flows_router.get("/langflow/available")
async def list_langflow_flows(langflow: LangflowClient = Depends(get_langflow)):
"""Browse flows on the Langflow authoring server (admin import source)."""
try:
return await langflow.list_flows()
except LangflowUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except LangflowError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@flows_router.post("/import", response_model=FlowImportRead, status_code=201)
async def import_flow(
data: FlowImportRequest,
session: Session = Depends(get_db),
langflow: LangflowClient = Depends(get_langflow),
lfx: LfxClient = Depends(get_lfx),
):
"""Import a flow from Langflow: export -> version -> (deploy to LFX)."""
try:
definition, version = await import_from_langflow(
session,
langflow,
lfx,
data.langflow_flow_id,
name=data.name,
author=data.author,
deploy=data.deploy,
)
except LangflowNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except LangflowUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except LangflowError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except store.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except store.InvalidError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except LfxInvalidFlow as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except LfxConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except LfxUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except LfxError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return FlowImportRead(
definition=_read_definition(definition), version=_read_version(version)
)
@flows_router.get("/runs/{run_id}", response_model=RunRead)
def get_run(run_id: UUID, session: Session = Depends(get_db)):
try:

View file

@ -0,0 +1,60 @@
"""Authoring pipeline: Langflow export -> versioned definition -> LFX deploy."""
from __future__ import annotations
from sqlmodel import Session
from app.models import FlowDefinition, FlowVersion
from . import store
from .langflow_client import LangflowClient
from .lfx_client import LfxClient
from .schemas import FlowDefinitionCreate, FlowVersionCreate
async def import_from_langflow(
session: Session,
langflow: LangflowClient,
lfx: LfxClient,
langflow_flow_id: str,
*,
name: str | None = None,
author: str = "",
deploy: bool = True,
) -> tuple[FlowDefinition, FlowVersion]:
"""Export a flow from Langflow, store it as a new version, deploy to LFX.
Definitions are matched by name (explicit `name` wins, else the exported
name); every import creates a new immutable version.
"""
export = await langflow.export_flow(langflow_flow_id)
flow_name = name or export.get("name") or f"langflow-{langflow_flow_id}"
langflow_json = {
"id": export.get("id", langflow_flow_id),
"name": flow_name,
"description": export.get("description") or "",
"endpoint_name": export.get("endpoint_name"),
"data": export.get("data", {}),
"tags": export.get("tags", []),
"webhook": export.get("webhook", False),
"is_component": export.get("is_component", False),
}
try:
definition = next(
d for d in store.list_definitions(session) if d.name == flow_name
)
except StopIteration:
definition = store.create_definition(
session, FlowDefinitionCreate(name=flow_name)
)
version = store.create_version(
session,
definition.id,
FlowVersionCreate(langflow_json=langflow_json, author=author),
)
if deploy:
uploaded = await lfx.upload_flow(
version.langflow_json, flow_id=str(version.id), replace=True
)
version = store.mark_deployed(session, version, uploaded.id)
return definition, version

View file

@ -0,0 +1,66 @@
"""Async client for the Langflow authoring server (visual designer).
Only used for the authoring pipeline (list/export flows). Execution always
goes through LFX (see lfx_client.py).
"""
from __future__ import annotations
import httpx
class LangflowError(Exception):
pass
class LangflowNotFound(LangflowError):
pass
class LangflowUnavailable(LangflowError):
pass
class LangflowClient:
"""Thin async wrapper around the Langflow `/api/v1/flows` endpoints."""
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60.0) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
def _headers(self) -> dict[str, str]:
return {"x-api-key": self.api_key} if self.api_key else {}
async def list_flows(self) -> list[dict]:
"""List flows (header mode keeps payloads small)."""
try:
async with httpx.AsyncClient(
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
) as client:
response = await client.get("/api/v1/flows/", params={"header_flows": "true"})
if response.status_code == 404:
raise LangflowNotFound("flow list not found")
response.raise_for_status()
data = response.json()
return data if isinstance(data, list) else data.get("items", [])
except httpx.HTTPStatusError as exc:
raise LangflowError(f"Langflow error {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc
async def export_flow(self, langflow_flow_id: str) -> dict:
"""Export one flow as Langflow JSON (FlowRead: id/name/description/data)."""
try:
async with httpx.AsyncClient(
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
) as client:
response = await client.get(f"/api/v1/flows/{langflow_flow_id}")
if response.status_code == 404:
raise LangflowNotFound(f"flow {langflow_flow_id} not found")
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
raise LangflowError(f"Langflow error {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc

View file

@ -84,3 +84,17 @@ class ApprovalRead(BaseModel):
decision_note: str
created_at: datetime
decided_at: datetime | None
class FlowImportRequest(BaseModel):
"""Import a flow from the Langflow authoring server by its Langflow id."""
langflow_flow_id: str = Field(min_length=1)
name: str | None = Field(default=None, max_length=200)
author: str = Field(default="", max_length=200)
deploy: bool = True
class FlowImportRead(BaseModel):
definition: FlowDefinitionRead
version: FlowVersionRead