mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 12:25:29 +08:00
190 lines
6.7 KiB
Python
190 lines
6.7 KiB
Python
"""DeerFlow LangGraph HTTP API client for Runner.
|
|
|
|
Ported from LangBot's legacy core DeerFlow client and kept plugin-local so the
|
|
runner can be distributed without importing LangBot core modules.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import codecs
|
|
import json
|
|
import typing
|
|
from collections.abc import AsyncGenerator
|
|
|
|
import httpx
|
|
|
|
from pkg.deadline import deadline
|
|
from pkg.endpoint import endpoint
|
|
from pkg.errors import DeerFlowAPIError
|
|
from pkg.http_limits import limited_body, limited_bytes, limited_post
|
|
|
|
SSE_MAX_BUFFER_CHARS = 1_048_576
|
|
|
|
|
|
def _normalize_sse_newlines(text: str) -> str:
|
|
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
|
|
|
|
def _parse_sse_data_lines(data_lines: list[str]) -> typing.Any:
|
|
raw_data = "\n".join(data_lines)
|
|
try:
|
|
return json.loads(raw_data)
|
|
except json.JSONDecodeError:
|
|
parsed_lines: list[typing.Any] = []
|
|
can_parse_all = True
|
|
for line in data_lines:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
parsed_lines.append(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
can_parse_all = False
|
|
break
|
|
if can_parse_all and parsed_lines:
|
|
return parsed_lines[0] if len(parsed_lines) == 1 else parsed_lines
|
|
return raw_data
|
|
|
|
|
|
def _parse_sse_block(block: str) -> dict[str, typing.Any] | None:
|
|
if not block.strip():
|
|
return None
|
|
|
|
event_name = "message"
|
|
data_lines: list[str] = []
|
|
for line in block.splitlines():
|
|
if line.startswith("event:"):
|
|
event_name = line[6:].strip()
|
|
elif line.startswith("data:"):
|
|
data_lines.append(line[5:].lstrip())
|
|
|
|
if not data_lines:
|
|
return None
|
|
return {"event": event_name, "data": _parse_sse_data_lines(data_lines)}
|
|
|
|
|
|
class AsyncDeerFlowClient:
|
|
"""Minimal DeerFlow LangGraph HTTP API client."""
|
|
|
|
def __init__(
|
|
self,
|
|
api_base: str = "http://127.0.0.1:2026",
|
|
api_key: str = "",
|
|
auth_header: str = "",
|
|
) -> None:
|
|
self.api_base = endpoint(api_base).rstrip("/")
|
|
self.headers: dict[str, str] = {}
|
|
if auth_header:
|
|
self.headers["Authorization"] = auth_header
|
|
elif api_key:
|
|
self.headers["Authorization"] = f"Bearer {api_key}"
|
|
|
|
@deadline
|
|
async def create_thread(self, timeout: float = 20) -> dict[str, typing.Any]:
|
|
"""Create a new LangGraph thread."""
|
|
url = f"{self.api_base}/api/langgraph/threads"
|
|
payload = {"metadata": {}}
|
|
|
|
try:
|
|
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as http_client:
|
|
response = await limited_post(
|
|
http_client,
|
|
url,
|
|
DeerFlowAPIError,
|
|
headers=self.headers,
|
|
json=payload,
|
|
)
|
|
except httpx.TimeoutException:
|
|
raise DeerFlowAPIError(
|
|
f"DeerFlow create thread timed out after {timeout}s",
|
|
operation="create thread",
|
|
url=url,
|
|
code="deerflow.timeout",
|
|
retryable=True,
|
|
) from None
|
|
if response.status_code not in (200, 201):
|
|
raise DeerFlowAPIError(
|
|
operation="create thread",
|
|
status=response.status_code,
|
|
body=response.text,
|
|
url=url,
|
|
code="deerflow.http_error",
|
|
)
|
|
return response.json()
|
|
|
|
@deadline
|
|
async def stream_run(
|
|
self,
|
|
thread_id: str,
|
|
payload: dict[str, typing.Any],
|
|
timeout: float = 120,
|
|
) -> AsyncGenerator[dict[str, typing.Any], None]:
|
|
"""Run a LangGraph stream request and yield parsed SSE events."""
|
|
url = f"{self.api_base}/api/langgraph/threads/{thread_id}/runs/stream"
|
|
stream_timeout = httpx.Timeout(
|
|
connect=min(timeout, 30),
|
|
read=timeout,
|
|
write=timeout,
|
|
pool=timeout,
|
|
)
|
|
|
|
try:
|
|
async with httpx.AsyncClient(trust_env=False, timeout=stream_timeout) as http_client:
|
|
async with http_client.stream(
|
|
"POST",
|
|
url,
|
|
headers={
|
|
**self.headers,
|
|
"Accept": "text/event-stream",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json=payload,
|
|
) as response:
|
|
if response.status_code != 200:
|
|
body = await limited_body(response, DeerFlowAPIError)
|
|
raise DeerFlowAPIError(
|
|
operation="runs/stream request",
|
|
status=response.status_code,
|
|
body=body.decode("utf-8", errors="replace"),
|
|
url=url,
|
|
thread_id=thread_id,
|
|
code="deerflow.http_error",
|
|
)
|
|
|
|
decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
|
buffer = ""
|
|
|
|
async for chunk in limited_bytes(response, DeerFlowAPIError):
|
|
buffer += _normalize_sse_newlines(decoder.decode(chunk))
|
|
|
|
while "\n\n" in buffer:
|
|
block, buffer = buffer.split("\n\n", 1)
|
|
if len(block) > SSE_MAX_BUFFER_CHARS:
|
|
raise DeerFlowAPIError("DeerFlow event exceeds the runtime limit")
|
|
parsed = _parse_sse_block(block)
|
|
if parsed is not None:
|
|
yield parsed
|
|
|
|
if len(buffer) > SSE_MAX_BUFFER_CHARS:
|
|
raise DeerFlowAPIError("DeerFlow event exceeds the runtime limit")
|
|
|
|
buffer += _normalize_sse_newlines(decoder.decode(b"", final=True))
|
|
while "\n\n" in buffer:
|
|
block, buffer = buffer.split("\n\n", 1)
|
|
parsed = _parse_sse_block(block)
|
|
if parsed is not None:
|
|
yield parsed
|
|
if buffer.strip():
|
|
parsed = _parse_sse_block(buffer)
|
|
if parsed is not None:
|
|
yield parsed
|
|
except httpx.TimeoutException:
|
|
raise DeerFlowAPIError(
|
|
f"DeerFlow stream timed out after {timeout}s",
|
|
operation="runs/stream request",
|
|
url=url,
|
|
thread_id=thread_id,
|
|
code="deerflow.timeout",
|
|
retryable=True,
|
|
) from None
|