mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-24 17:06:00 +00:00
refactor(routers): reorganize routers with new langgraph/ subdirectory
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>
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user