5f2f1941e9
Restructure app/gateway/routers/: - Add langgraph/ subdirectory for LangGraph-related endpoints: - threads.py - thread management - runs.py - run execution and streaming - feedback.py - feedback endpoints - suggestions.py - follow-up suggestions Remove old standalone routers: - threads.py → langgraph/threads.py - thread_runs.py → langgraph/runs.py - runs.py (stateless) → langgraph/runs.py - feedback.py → langgraph/feedback.py Update existing routers: - memory.py, uploads.py, artifacts.py, suggestions.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
502 lines
18 KiB
Python
502 lines
18 KiB
Python
"""LangGraph-compatible runs endpoints backed by RunsFacade."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import AsyncIterator
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from fastapi.responses import Response, StreamingResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.plugins.auth.security.actor_context import bind_request_actor_context
|
|
from app.gateway.services.runs.facade_factory import build_runs_facade_from_request
|
|
from app.gateway.services.runs.input import (
|
|
AdaptedRunRequest,
|
|
RunSpecBuilder,
|
|
UnsupportedRunFeatureError,
|
|
adapt_create_run_request,
|
|
adapt_create_stream_request,
|
|
adapt_create_wait_request,
|
|
adapt_join_stream_request,
|
|
adapt_join_wait_request,
|
|
)
|
|
from deerflow.runtime.runs.types import RunRecord, RunSpec
|
|
from deerflow.runtime.stream_bridge import JSONValue, StreamEvent
|
|
|
|
router = APIRouter(tags=["runs"])
|
|
|
|
|
|
class RunCreateRequest(BaseModel):
|
|
assistant_id: str | None = Field(default=None, description="Agent / assistant to use")
|
|
follow_up_to_run_id: str | None = Field(default=None, description="Lineage link to the prior run")
|
|
input: dict[str, JSONValue] | None = Field(default=None, description="Graph input (e.g. {messages: [...]})")
|
|
command: dict[str, JSONValue] | None = Field(default=None, description="LangGraph Command")
|
|
metadata: dict[str, JSONValue] | None = Field(default=None, description="Run metadata")
|
|
config: dict[str, JSONValue] | None = Field(default=None, description="RunnableConfig overrides")
|
|
context: dict[str, JSONValue] | None = Field(default=None, description="DeerFlow context overrides (model_name, thinking_enabled, etc.)")
|
|
webhook: str | None = Field(default=None, description="Completion callback URL")
|
|
checkpoint_id: str | None = Field(default=None, description="Resume from checkpoint")
|
|
checkpoint: dict[str, JSONValue] | None = Field(default=None, description="Full checkpoint object")
|
|
interrupt_before: list[str] | Literal["*"] | None = Field(default=None, description="Nodes to interrupt before")
|
|
interrupt_after: list[str] | Literal["*"] | None = Field(default=None, description="Nodes to interrupt after")
|
|
stream_mode: list[str] | str | None = Field(default=None, description="Stream mode(s)")
|
|
stream_subgraphs: bool = Field(default=False, description="Include subgraph events")
|
|
stream_resumable: bool | None = Field(default=None, description="SSE resumable mode")
|
|
on_disconnect: Literal["cancel", "continue"] = Field(default="cancel", description="Behaviour on SSE disconnect")
|
|
on_completion: Literal["delete", "keep"] = Field(default="keep", description="Delete temp thread on completion")
|
|
multitask_strategy: Literal["reject", "rollback", "interrupt", "enqueue"] = Field(default="reject", description="Concurrency strategy")
|
|
after_seconds: float | None = Field(default=None, description="Delayed execution")
|
|
if_not_exists: Literal["reject", "create"] = Field(default="create", description="Thread creation policy")
|
|
feedback_keys: list[str] | None = Field(default=None, description="LangSmith feedback keys")
|
|
|
|
|
|
class RunResponse(BaseModel):
|
|
run_id: str
|
|
thread_id: str
|
|
assistant_id: str | None = None
|
|
status: str
|
|
metadata: dict[str, JSONValue] = Field(default_factory=dict)
|
|
multitask_strategy: str = "reject"
|
|
created_at: str = ""
|
|
updated_at: str = ""
|
|
|
|
|
|
class RunDeleteResponse(BaseModel):
|
|
deleted: bool
|
|
|
|
|
|
class RunMessageResponse(BaseModel):
|
|
run_id: str
|
|
content: JSONValue
|
|
metadata: dict[str, JSONValue] = Field(default_factory=dict)
|
|
created_at: str
|
|
seq: int
|
|
|
|
|
|
class RunMessagesResponse(BaseModel):
|
|
data: list[RunMessageResponse]
|
|
hasMore: bool = False
|
|
|
|
|
|
def format_sse(event: str, data: JSONValue, *, event_id: str | None = None) -> str:
|
|
"""Format a single SSE frame."""
|
|
payload = json.dumps(data, default=str, ensure_ascii=False)
|
|
parts = [f"event: {event}", f"data: {payload}"]
|
|
if event_id:
|
|
parts.append(f"id: {event_id}")
|
|
parts.append("")
|
|
parts.append("")
|
|
return "\n".join(parts)
|
|
|
|
|
|
def _record_to_response(record: RunRecord) -> RunResponse:
|
|
return RunResponse(
|
|
run_id=record.run_id,
|
|
thread_id=record.thread_id,
|
|
assistant_id=record.assistant_id,
|
|
status=record.status,
|
|
metadata=record.metadata,
|
|
multitask_strategy=record.multitask_strategy,
|
|
created_at=record.created_at,
|
|
updated_at=record.updated_at,
|
|
)
|
|
|
|
|
|
def _trim_paginated_rows(
|
|
rows: list[dict],
|
|
*,
|
|
limit: int,
|
|
after_seq: int | None,
|
|
) -> tuple[list[dict], bool]:
|
|
has_more = len(rows) > limit
|
|
if not has_more:
|
|
return rows, False
|
|
if after_seq is not None:
|
|
return rows[:limit], True
|
|
return rows[-limit:], True
|
|
|
|
|
|
def _event_to_run_message(event: dict) -> RunMessageResponse:
|
|
return RunMessageResponse(
|
|
run_id=str(event["run_id"]),
|
|
content=event.get("content"),
|
|
metadata=dict(event.get("metadata") or {}),
|
|
created_at=str(event.get("created_at") or ""),
|
|
seq=int(event["seq"]),
|
|
)
|
|
|
|
|
|
async def _sse_consumer(
|
|
stream: AsyncIterator[StreamEvent],
|
|
request: Request,
|
|
*,
|
|
cancel_on_disconnect: bool,
|
|
cancel_run,
|
|
run_id: str,
|
|
) -> AsyncIterator[str]:
|
|
try:
|
|
async for event in stream:
|
|
if await request.is_disconnected():
|
|
break
|
|
|
|
if event.event == "__heartbeat__":
|
|
yield ": heartbeat\n\n"
|
|
continue
|
|
|
|
if event.event == "__end__":
|
|
yield format_sse("end", None, event_id=event.id or None)
|
|
return
|
|
|
|
if event.event == "__cancelled__":
|
|
yield format_sse("cancel", None, event_id=event.id or None)
|
|
return
|
|
|
|
yield format_sse(event.event, event.data, event_id=event.id or None)
|
|
finally:
|
|
if cancel_on_disconnect:
|
|
await cancel_run(run_id)
|
|
|
|
|
|
def _get_run_event_store(request: Request):
|
|
event_store = getattr(request.app.state, "run_event_store", None)
|
|
if event_store is None:
|
|
raise HTTPException(status_code=503, detail="Run event store not available")
|
|
return event_store
|
|
|
|
|
|
@router.get("/{thread_id}/runs", response_model=list[RunResponse])
|
|
async def list_runs(
|
|
thread_id: str,
|
|
request: Request,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
status: str | None = None,
|
|
) -> list[RunResponse]:
|
|
# Accepted for API compatibility; field projection is not implemented yet.
|
|
facade = build_runs_facade_from_request(request)
|
|
with bind_request_actor_context(request):
|
|
records = await facade.list_runs(thread_id)
|
|
if status is not None:
|
|
records = [record for record in records if record.status == status]
|
|
records = records[offset : offset + limit]
|
|
return [_record_to_response(record) for record in records]
|
|
|
|
|
|
@router.get("/{thread_id}/runs/{run_id}", response_model=RunResponse)
|
|
async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse:
|
|
facade = build_runs_facade_from_request(request)
|
|
with bind_request_actor_context(request):
|
|
record = await facade.get_run(run_id)
|
|
if record is None or record.thread_id != thread_id:
|
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
return _record_to_response(record)
|
|
|
|
|
|
@router.get("/{thread_id}/runs/{run_id}/messages", response_model=RunMessagesResponse)
|
|
async def run_messages(
|
|
thread_id: str,
|
|
run_id: str,
|
|
request: Request,
|
|
limit: int = 50,
|
|
before_seq: int | None = None,
|
|
after_seq: int | None = None,
|
|
) -> RunMessagesResponse:
|
|
facade = build_runs_facade_from_request(request)
|
|
with bind_request_actor_context(request):
|
|
record = await facade.get_run(run_id)
|
|
if record is None or record.thread_id != thread_id:
|
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
|
|
event_store = _get_run_event_store(request)
|
|
with bind_request_actor_context(request):
|
|
rows = await event_store.list_messages_by_run(
|
|
thread_id,
|
|
run_id,
|
|
limit=limit + 1,
|
|
before_seq=before_seq,
|
|
after_seq=after_seq,
|
|
)
|
|
page, has_more = _trim_paginated_rows(rows, limit=limit, after_seq=after_seq)
|
|
return RunMessagesResponse(data=[_event_to_run_message(row) for row in page], hasMore=has_more)
|
|
|
|
|
|
def _build_spec(
|
|
*,
|
|
adapted: AdaptedRunRequest,
|
|
) -> RunSpec:
|
|
try:
|
|
return RunSpecBuilder().build(adapted)
|
|
except UnsupportedRunFeatureError as exc:
|
|
raise HTTPException(status_code=501, detail=str(exc)) from exc
|
|
|
|
|
|
@router.post("/{thread_id}/runs", response_model=RunResponse)
|
|
async def create_run(
|
|
thread_id: str,
|
|
body: RunCreateRequest,
|
|
request: Request,
|
|
) -> Response:
|
|
adapted = adapt_create_run_request(
|
|
thread_id=thread_id,
|
|
body=body.model_dump(),
|
|
headers=dict(request.headers),
|
|
query=dict(request.query_params),
|
|
)
|
|
spec = _build_spec(adapted=adapted)
|
|
facade = build_runs_facade_from_request(request)
|
|
with bind_request_actor_context(request):
|
|
record = await facade.create_background(spec)
|
|
return Response(
|
|
content=_record_to_response(record).model_dump_json(),
|
|
media_type="application/json",
|
|
headers={"Content-Location": f"/api/threads/{thread_id}/runs/{record.run_id}"},
|
|
)
|
|
|
|
|
|
@router.post("/{thread_id}/runs/stream")
|
|
async def stream_run(
|
|
thread_id: str,
|
|
body: RunCreateRequest,
|
|
request: Request,
|
|
) -> StreamingResponse:
|
|
adapted = adapt_create_stream_request(
|
|
thread_id=thread_id,
|
|
body=body.model_dump(),
|
|
headers=dict(request.headers),
|
|
query=dict(request.query_params),
|
|
)
|
|
|
|
spec = _build_spec(adapted=adapted)
|
|
|
|
facade = build_runs_facade_from_request(request)
|
|
with bind_request_actor_context(request):
|
|
record, stream = await facade.create_and_stream(spec)
|
|
|
|
return StreamingResponse(
|
|
_sse_consumer(
|
|
stream,
|
|
request,
|
|
cancel_on_disconnect=spec.on_disconnect == "cancel",
|
|
cancel_run=facade.cancel,
|
|
run_id=record.run_id,
|
|
),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
"Content-Location": f"/api/threads/{thread_id}/runs/{record.run_id}",
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/{thread_id}/runs/wait")
|
|
async def wait_run(
|
|
thread_id: str,
|
|
body: RunCreateRequest,
|
|
request: Request,
|
|
) -> Response:
|
|
adapted = adapt_create_wait_request(
|
|
thread_id=thread_id,
|
|
body=body.model_dump(),
|
|
headers=dict(request.headers),
|
|
query=dict(request.query_params),
|
|
)
|
|
spec = _build_spec(adapted=adapted)
|
|
facade = build_runs_facade_from_request(request)
|
|
with bind_request_actor_context(request):
|
|
record, result = await facade.create_and_wait(spec)
|
|
return Response(
|
|
content=json.dumps(result, default=str, ensure_ascii=False),
|
|
media_type="application/json",
|
|
headers={"Content-Location": f"/api/threads/{thread_id}/runs/{record.run_id}"},
|
|
)
|
|
|
|
|
|
@router.post("/runs", response_model=RunResponse)
|
|
async def create_stateless_run(body: RunCreateRequest, request: Request) -> Response:
|
|
adapted = adapt_create_run_request(
|
|
thread_id=None,
|
|
body=body.model_dump(),
|
|
headers=dict(request.headers),
|
|
query=dict(request.query_params),
|
|
)
|
|
spec = _build_spec(adapted=adapted)
|
|
facade = build_runs_facade_from_request(request)
|
|
with bind_request_actor_context(request):
|
|
record = await facade.create_background(spec)
|
|
return Response(
|
|
content=_record_to_response(record).model_dump_json(),
|
|
media_type="application/json",
|
|
headers={"Content-Location": f"/api/threads/{record.thread_id}/runs/{record.run_id}"},
|
|
)
|
|
|
|
|
|
@router.post("/runs/stream")
|
|
async def create_stateless_stream_run(body: RunCreateRequest, request: Request) -> StreamingResponse:
|
|
adapted = adapt_create_stream_request(
|
|
thread_id=None,
|
|
body=body.model_dump(),
|
|
headers=dict(request.headers),
|
|
query=dict(request.query_params),
|
|
)
|
|
spec = _build_spec(adapted=adapted)
|
|
facade = build_runs_facade_from_request(request)
|
|
with bind_request_actor_context(request):
|
|
record, stream = await facade.create_and_stream(spec)
|
|
|
|
return StreamingResponse(
|
|
_sse_consumer(
|
|
stream,
|
|
request,
|
|
cancel_on_disconnect=spec.on_disconnect == "cancel",
|
|
cancel_run=facade.cancel,
|
|
run_id=record.run_id,
|
|
),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
"Content-Location": f"/api/threads/{record.thread_id}/runs/{record.run_id}",
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/runs/wait")
|
|
async def wait_stateless_run(body: RunCreateRequest, request: Request) -> Response:
|
|
adapted = adapt_create_wait_request(
|
|
thread_id=None,
|
|
body=body.model_dump(),
|
|
headers=dict(request.headers),
|
|
query=dict(request.query_params),
|
|
)
|
|
spec = _build_spec(adapted=adapted)
|
|
facade = build_runs_facade_from_request(request)
|
|
with bind_request_actor_context(request):
|
|
record, result = await facade.create_and_wait(spec)
|
|
return Response(
|
|
content=json.dumps(result, default=str, ensure_ascii=False),
|
|
media_type="application/json",
|
|
headers={"Content-Location": f"/api/threads/{record.thread_id}/runs/{record.run_id}"},
|
|
)
|
|
|
|
|
|
@router.api_route("/{thread_id}/runs/{run_id}/stream", methods=["GET", "POST"], response_model=None)
|
|
async def stream_existing_run(
|
|
thread_id: str,
|
|
run_id: str,
|
|
request: Request,
|
|
action: Literal["interrupt", "rollback"] | None = None,
|
|
wait: bool = False,
|
|
cancel_on_disconnect: bool = False,
|
|
stream_mode: str | None = None,
|
|
) -> StreamingResponse | Response:
|
|
facade = build_runs_facade_from_request(request)
|
|
with bind_request_actor_context(request):
|
|
record = await facade.get_run(run_id)
|
|
if record is None or record.thread_id != thread_id:
|
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
|
|
if action is not None:
|
|
with bind_request_actor_context(request):
|
|
cancelled = await facade.cancel(run_id, action=action)
|
|
if not cancelled:
|
|
raise HTTPException(status_code=409, detail=f"Run {run_id} is not cancellable")
|
|
if wait:
|
|
with bind_request_actor_context(request):
|
|
await facade.join_wait(run_id)
|
|
return Response(status_code=204)
|
|
|
|
adapted = adapt_join_stream_request(
|
|
thread_id=thread_id,
|
|
run_id=run_id,
|
|
headers=dict(request.headers),
|
|
query=dict(request.query_params),
|
|
)
|
|
with bind_request_actor_context(request):
|
|
stream = await facade.join_stream(run_id, last_event_id=adapted.last_event_id)
|
|
|
|
return StreamingResponse(
|
|
_sse_consumer(
|
|
stream,
|
|
request,
|
|
cancel_on_disconnect=cancel_on_disconnect,
|
|
cancel_run=facade.cancel,
|
|
run_id=run_id,
|
|
),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/{thread_id}/runs/{run_id}/join")
|
|
async def join_existing_run(
|
|
thread_id: str,
|
|
run_id: str,
|
|
request: Request,
|
|
cancel_on_disconnect: bool = False,
|
|
) -> JSONValue:
|
|
# Accepted for API compatibility; current join_wait path does not change
|
|
# behavior based on client disconnect.
|
|
facade = build_runs_facade_from_request(request)
|
|
with bind_request_actor_context(request):
|
|
record = await facade.get_run(run_id)
|
|
if record is None or record.thread_id != thread_id:
|
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
|
|
adapted = adapt_join_wait_request(
|
|
thread_id=thread_id,
|
|
run_id=run_id,
|
|
headers=dict(request.headers),
|
|
query=dict(request.query_params),
|
|
)
|
|
with bind_request_actor_context(request):
|
|
return await facade.join_wait(run_id, last_event_id=adapted.last_event_id)
|
|
|
|
|
|
@router.post("/{thread_id}/runs/{run_id}/cancel")
|
|
async def cancel_existing_run(
|
|
thread_id: str,
|
|
run_id: str,
|
|
request: Request,
|
|
wait: bool = False,
|
|
action: Literal["interrupt", "rollback"] = "interrupt",
|
|
) -> JSONValue:
|
|
facade = build_runs_facade_from_request(request)
|
|
with bind_request_actor_context(request):
|
|
record = await facade.get_run(run_id)
|
|
if record is None or record.thread_id != thread_id:
|
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
|
|
with bind_request_actor_context(request):
|
|
cancelled = await facade.cancel(run_id, action=action)
|
|
if not cancelled:
|
|
raise HTTPException(status_code=409, detail=f"Run {run_id} is not cancellable")
|
|
if wait:
|
|
with bind_request_actor_context(request):
|
|
return await facade.join_wait(run_id)
|
|
return {}
|
|
|
|
|
|
@router.delete("/{thread_id}/runs/{run_id}", response_model=RunDeleteResponse)
|
|
async def delete_run(
|
|
thread_id: str,
|
|
run_id: str,
|
|
request: Request,
|
|
) -> RunDeleteResponse:
|
|
facade = build_runs_facade_from_request(request)
|
|
with bind_request_actor_context(request):
|
|
record = await facade.get_run(run_id)
|
|
if record is None or record.thread_id != thread_id:
|
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
with bind_request_actor_context(request):
|
|
deleted = await facade.delete_run(run_id)
|
|
return RunDeleteResponse(deleted=deleted)
|