mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-12 15:59:04 +00:00
* refactor(memory): pluggable MemoryManager interface for backend onboarding
Optimize the MemoryManager interface layer so new backends (mem0/openviking)
onboard with less code and the contract stays stable as capabilities are
added. A minimal backend now implements only from_config + add + get_context
(verified by test_memory_manager_interface.py::_MinimalBackend onboarding via
the factory); the factory no longer knows a backend's private hooks.
- MemoryManager: ABC -> pydantic BaseModel; three-tier methods (tier-1
add/get_context abstract; tier-2 management defaults; tier-3 optional hooks
warm/reload/fact + on_pre_compress/on_turn_start). Dropped 3 self-serving
hooks. 6 hasattr probe sites -> direct call + try/except NotImplementedError.
- from_config classmethod: factory thins to resolve + inject storage_path +
collect host hooks + call from_config; DeerMem-specific hook consumption
moved from factory to DeerMem.from_config.
- Invariants: @model_validator (mode='tool' requires search via supports_search
ClassVar); DeerMemConfig storage_path-is-file check moved here from factory.
- Async: aadd/aget_context/asearch default to the sync path (speculative).
- Callbacks: MemoryCallbacks + LangfuseMemoryCallbacks; on_memory_llm_call
subsumes tracing_callback (same signature/timing/mutation); deleted the
tracing_callback field. DeerMem decoupled from langfuse (portability).
- noop keeps read-op empty overrides (avoids router 500s on the
disable-memory-via-noop path); only delete/export inherit the base raise.
Behavior preserved: 661 passed / 13 skipped. Docs: backends/README.md rewritten
(three-tier + from_config + callbacks); samples README updated; removed stale
private doc paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(memory): 501 on unsupported read/manage endpoints + accurate warm log
Review follow-up on the three-tier MemoryManager refactor.
- Read/manage endpoints (GET /memory, /memory/export, /memory/status,
DELETE /memory, POST /memory/import) and the /memory/reload fallback now
catch NotImplementedError -> 501, matching the fact-CRUD endpoints. The
hasattr->try/except migration had skipped these: they were @abstractmethod
before (every backend implemented them, so they never raised), so once they
became tier-2 default-raise a minimal backend (only add + get_context) hit a
raw 500 -- there is no global NotImplementedError handler. get_memory is
shared via _get_memory_or_501 (covers /memory, export, status, reload
fallback). noop is unchanged: its read-op empty overrides never raise.
- warm() base default returns None (tri-state: True=warmed, False=failed,
None=nothing to warm) so the Gateway lifespan logs "skipping" for a
non-DeerMem backend (e.g. noop) instead of the inaccurate "warmed
successfully" it never earned. DeerMem.warm keeps True/False.
- Tests: 6 router 501 tests (read/manage + reload fallback) + 2 lifespan
warm-log tests (None->skipping, False->warning); conformance/pluggable
assert warm() is None.
705 passed / 13 skipped; lint clean.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(memory): review follow-ups - search-flag consistency, client reload, backend_config purity
Address review feedback on the three-tier MemoryManager refactor:
- [Medium] supports_search/search drift: the invariant now requires the
supports_search ClassVar flag to MATCH whether search() is actually
overridden (type(self).search is not MemoryManager.search), so the flag
can't drift from the impl. Catches both directions at instantiation: a
backend that overrides search() but forgets supports_search=True (was a
misleading tool-mode rejection), and one that sets the flag without
overriding (was a runtime NotImplementedError on the first memory_search).
noop sets supports_search=True to match its search() override. Conformance
adds drift + consistent-backend tests.
- [Low] client.reload_memory fallback: wrap the get_memory fallback so a
minimal backend (only add + get_context) surfaces a clean NotImplementedError
("implements neither reload_memory nor get_memory") instead of an uncaught
propagation -- mirrors the router's 501. Test added.
- [Low] backend_config purity: DeerMem.from_config restores backend_config to
the pure data the host passed after model_post_init parses the injected hooks
into DeerMemConfig (self._config, PrivateAttr); the field stays serializable
(no callables/LLM) and matches the README ("host hooks NOT in backend_config").
Test asserts purity + hooks wired.
- [Low] CHANGELOG: breaking-change note that mode='tool' + non-search backend
now fails fast at startup (was silently empty) so operators recognize it on
upgrade.
- [Nit] .gitignore: drop the env-specific .tmp-pytest/ entry (--basetemp is
local-only, not make test/CI).
709 passed / 13 skipped; lint clean.
Co-Authored-By: Claude <noreply@anthropic.com>
* docs(changelog): correct memory tool-mode fail-fast note
The CHANGELOG entry said mode='tool' + a non-search backend "(e.g. noop)"
fails fast at startup, but noop overrides search() (returns []) and sets
supports_search=True (required by the consistency invariant), so noop IS
search-capable and noop+tool does NOT fail fast. The fail-fast only affects a
custom backend that onboards without overriding search(). Reworded to drop the
misleading noop example and state both shipping backends implement search().
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
594 lines
24 KiB
Python
594 lines
24 KiB
Python
import asyncio
|
|
import logging
|
|
from collections.abc import AsyncGenerator
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
from app.gateway.auth_disabled import warn_if_auth_disabled_enabled
|
|
from app.gateway.auth_middleware import AuthMiddleware
|
|
from app.gateway.browser_capability import ensure_browser_runtime_available
|
|
from app.gateway.config import get_gateway_config
|
|
from app.gateway.csrf_middleware import CSRFMiddleware, get_configured_cors_origins
|
|
from app.gateway.deps import langgraph_runtime
|
|
from app.gateway.routers import (
|
|
agents,
|
|
artifacts,
|
|
assistants_compat,
|
|
auth,
|
|
browser,
|
|
channel_connections,
|
|
channels,
|
|
console,
|
|
features,
|
|
feedback,
|
|
github_webhooks,
|
|
input_polish,
|
|
mcp,
|
|
memory,
|
|
models,
|
|
runs,
|
|
scheduled_tasks,
|
|
skills,
|
|
suggestions,
|
|
thread_runs,
|
|
threads,
|
|
uploads,
|
|
)
|
|
from app.gateway.trace_middleware import TraceMiddleware, resolve_trace_enabled
|
|
from deerflow.config import app_config as deerflow_app_config
|
|
from deerflow.logging_config import DEFAULT_LOG_DATE_FORMAT, DEFAULT_LOG_FORMAT, configure_logging
|
|
from deerflow.tracing.monocle import setup_monocle_tracing_if_enabled
|
|
from deerflow.uploads.manager import cleanup_stale_upload_staging_files
|
|
|
|
AppConfig = deerflow_app_config.AppConfig
|
|
get_app_config = deerflow_app_config.get_app_config
|
|
|
|
# Default logging; lifespan overrides from config.yaml log_level.
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format=DEFAULT_LOG_FORMAT,
|
|
datefmt=DEFAULT_LOG_DATE_FORMAT,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Upper bound (seconds) each lifespan shutdown hook is allowed to run.
|
|
# Bounds worker exit time so uvicorn's reload supervisor does not keep
|
|
# firing signals into a worker that is stuck waiting for shutdown cleanup.
|
|
_SHUTDOWN_HOOK_TIMEOUT_SECONDS = 5.0
|
|
|
|
|
|
async def _ensure_admin_user(app: FastAPI) -> None:
|
|
"""Startup hook: handle first boot and migrate orphan threads otherwise.
|
|
|
|
After admin creation, migrate orphan threads from the LangGraph
|
|
store (metadata.user_id unset) to the admin account. This is the
|
|
"no-auth → with-auth" upgrade path: users who ran DeerFlow without
|
|
authentication have existing LangGraph thread data that needs an
|
|
owner assigned.
|
|
First boot (no admin exists):
|
|
- Does NOT create any user accounts automatically.
|
|
- The operator must visit ``/setup`` to create the first admin.
|
|
|
|
Subsequent boots (admin already exists):
|
|
- Runs the one-time "no-auth → with-auth" orphan thread migration for
|
|
existing LangGraph thread metadata that has no user_id.
|
|
|
|
No SQL persistence migration is needed: the four user_id columns
|
|
(threads_meta, runs, run_events, feedback) only come into existence
|
|
alongside the auth module via create_all, so freshly created tables
|
|
never contain NULL-owner rows.
|
|
"""
|
|
from sqlalchemy import select
|
|
|
|
from app.gateway.deps import get_local_provider
|
|
from deerflow.persistence.engine import get_session_factory
|
|
from deerflow.persistence.user.model import UserRow
|
|
|
|
try:
|
|
provider = get_local_provider()
|
|
except RuntimeError:
|
|
# Auth persistence may not be initialized in some test/boot paths.
|
|
# Skip admin migration work rather than failing gateway startup.
|
|
logger.warning("Auth persistence not ready; skipping admin bootstrap check")
|
|
return
|
|
|
|
sf = get_session_factory()
|
|
if sf is None:
|
|
return
|
|
|
|
admin_count = await provider.count_admin_users()
|
|
|
|
if admin_count == 0:
|
|
logger.info("=" * 60)
|
|
logger.info(" First boot detected — no admin account exists.")
|
|
logger.info(" Visit /setup to complete admin account creation.")
|
|
logger.info("=" * 60)
|
|
return
|
|
|
|
# Admin already exists — run orphan thread migration for any
|
|
# LangGraph thread metadata that pre-dates the auth module.
|
|
async with sf() as session:
|
|
stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
|
|
row = (await session.execute(stmt)).scalar_one_or_none()
|
|
|
|
if row is None:
|
|
return # Should not happen (admin_count > 0 above), but be safe.
|
|
|
|
admin_id = str(row.id)
|
|
|
|
# LangGraph store orphan migration — non-fatal.
|
|
# This covers the "no-auth → with-auth" upgrade path for users
|
|
# whose existing LangGraph thread metadata has no user_id set.
|
|
store = getattr(app.state, "store", None)
|
|
if store is not None:
|
|
try:
|
|
migrated = await _migrate_orphaned_threads(store, admin_id)
|
|
if migrated:
|
|
logger.info("Migrated %d orphan LangGraph thread(s) to admin", migrated)
|
|
except Exception:
|
|
logger.exception("LangGraph thread migration failed (non-fatal)")
|
|
|
|
|
|
async def _iter_store_items(store, namespace, *, page_size: int = 500):
|
|
"""Paginated async iterator over a LangGraph store namespace.
|
|
|
|
Replaces the old hardcoded ``limit=1000`` call with a cursor-style
|
|
loop so that environments with more than one page of orphans do
|
|
not silently lose data. Terminates when a page is empty OR when a
|
|
short page arrives (indicating the last page).
|
|
"""
|
|
offset = 0
|
|
while True:
|
|
batch = await store.asearch(namespace, limit=page_size, offset=offset)
|
|
if not batch:
|
|
return
|
|
for item in batch:
|
|
yield item
|
|
if len(batch) < page_size:
|
|
return
|
|
offset += page_size
|
|
|
|
|
|
async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
|
"""Migrate LangGraph store threads with no user_id to the given admin.
|
|
|
|
Uses cursor pagination so all orphans are migrated regardless of
|
|
count. Returns the number of rows migrated.
|
|
"""
|
|
migrated = 0
|
|
async for item in _iter_store_items(store, ("threads",)):
|
|
metadata = item.value.get("metadata", {})
|
|
if not metadata.get("user_id"):
|
|
metadata["user_id"] = admin_user_id
|
|
item.value["metadata"] = metadata
|
|
await store.aput(("threads",), item.key, item.value)
|
|
migrated += 1
|
|
return migrated
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
"""Application lifespan handler."""
|
|
|
|
# Load config and check necessary environment variables at startup.
|
|
# `startup_config` is a local snapshot used only for one-shot bootstrap
|
|
# work (logging level, langgraph_runtime engines, channels). Request-time
|
|
# config resolution always routes through `get_app_config()` in
|
|
# `app/gateway/deps.py::get_config()` so `config.yaml` edits become
|
|
# visible without a process restart. We deliberately do NOT cache this
|
|
# snapshot on `app.state` to keep that contract enforceable.
|
|
try:
|
|
startup_config = get_app_config()
|
|
configure_logging(startup_config)
|
|
ensure_browser_runtime_available(startup_config)
|
|
logger.info("Configuration loaded successfully")
|
|
warn_if_auth_disabled_enabled()
|
|
except Exception as e:
|
|
error_msg = f"Failed to load configuration during gateway startup: {e}"
|
|
logger.exception(error_msg)
|
|
raise RuntimeError(error_msg) from e
|
|
config = get_gateway_config()
|
|
logger.info(f"Starting API Gateway on {config.host}:{config.port}")
|
|
|
|
# Agent observability (Monocle). Off by default; enabled with
|
|
# MONOCLE_TRACING. Initialized here at startup — not at import time — so a
|
|
# plain `import deerflow.agents` never installs a process-global tracer.
|
|
# Unlike LangSmith/Langfuse, whose validation failures abort the agent run,
|
|
# a bad Monocle config only logs: the Gateway keeps serving without tracing.
|
|
try:
|
|
setup_monocle_tracing_if_enabled()
|
|
except Exception: # observability must never break startup
|
|
logger.exception("Monocle tracing setup failed; continuing without it")
|
|
|
|
# Pre-warm tiktoken encoding cache so the first memory-injection request
|
|
# never blocks on the BPE data download (which hits an OpenAI/Azure URL
|
|
# that may be unreachable in restricted networks — see issue #3402).
|
|
# Warm-up runs via the manager's `warm()` tier-3 hook. DeerMem.warm re-checks
|
|
# token_counting=="char" and returns early, so char-mode backends never touch
|
|
# tiktoken (avoids even the 5s probe in network-restricted deployments - see
|
|
# issue #3429). A backend with nothing to warm (e.g. noop) returns None from
|
|
# the base default -- log "skipping" instead of the misleading "warmed
|
|
# successfully" so the log reflects what actually happened.
|
|
try:
|
|
from deerflow.agents.memory import get_memory_manager
|
|
|
|
manager = get_memory_manager()
|
|
warmed = await asyncio.wait_for(
|
|
asyncio.to_thread(manager.warm),
|
|
timeout=5,
|
|
)
|
|
if warmed is None:
|
|
logger.info("Memory backend %s has nothing to warm; skipping tiktoken warm-up", type(manager).__name__)
|
|
elif warmed:
|
|
logger.info("tiktoken encoding cache warmed successfully")
|
|
else:
|
|
logger.warning("tiktoken encoding cache warm-up failed; token counting will use character-based fallback until tiktoken loads successfully")
|
|
except TimeoutError:
|
|
logger.warning("tiktoken encoding cache warm-up timed out; token counting will use character-based fallback until tiktoken loads successfully")
|
|
except Exception:
|
|
logger.warning("tiktoken warm-up skipped", exc_info=True)
|
|
|
|
try:
|
|
removed_upload_staging_files = await asyncio.to_thread(cleanup_stale_upload_staging_files)
|
|
if removed_upload_staging_files:
|
|
logger.info("Removed %d stale upload staging file(s)", removed_upload_staging_files)
|
|
except Exception:
|
|
logger.warning("Upload staging file cleanup skipped", exc_info=True)
|
|
|
|
# Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store)
|
|
async with langgraph_runtime(app, startup_config):
|
|
logger.info("LangGraph runtime initialised")
|
|
|
|
# Check admin bootstrap state and migrate orphan threads after admin exists.
|
|
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
|
|
await _ensure_admin_user(app)
|
|
|
|
# Start IM channel service if any channels are configured
|
|
try:
|
|
from app.channels.service import start_channel_service
|
|
|
|
channel_service = await start_channel_service(startup_config)
|
|
logger.info("Channel service started: %s", channel_service.get_status())
|
|
except Exception:
|
|
logger.exception("No IM channels configured or channel service failed to start")
|
|
|
|
try:
|
|
from app.gateway.services import launch_scheduled_thread_run
|
|
from app.scheduler import ScheduledTaskService
|
|
|
|
if getattr(app.state, "scheduled_task_repo", None) is not None and getattr(app.state, "scheduled_task_run_repo", None) is not None:
|
|
scheduled_task_service = ScheduledTaskService(
|
|
task_repo=app.state.scheduled_task_repo,
|
|
task_run_repo=app.state.scheduled_task_run_repo,
|
|
launch_run=lambda **kwargs: launch_scheduled_thread_run(app=app, **kwargs),
|
|
poll_interval_seconds=startup_config.scheduler.poll_interval_seconds,
|
|
lease_seconds=startup_config.scheduler.lease_seconds,
|
|
max_concurrent_runs=startup_config.scheduler.max_concurrent_runs,
|
|
)
|
|
app.state.scheduled_task_service = scheduled_task_service
|
|
if startup_config.scheduler.enabled:
|
|
await scheduled_task_service.start()
|
|
except Exception:
|
|
logger.exception("Failed to initialize scheduled task service")
|
|
|
|
yield
|
|
|
|
try:
|
|
await auth.close_oidc_service()
|
|
except Exception:
|
|
logger.exception("Failed to close OIDC service")
|
|
|
|
# Stop channel service on shutdown (bounded to prevent worker hang)
|
|
try:
|
|
from app.channels.service import stop_channel_service
|
|
|
|
await asyncio.wait_for(
|
|
stop_channel_service(),
|
|
timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
|
|
)
|
|
except TimeoutError:
|
|
logger.warning(
|
|
"Channel service shutdown exceeded %.1fs; proceeding with worker exit.",
|
|
_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
|
|
)
|
|
except Exception:
|
|
logger.exception("Failed to stop channel service")
|
|
|
|
if getattr(app.state, "scheduled_task_service", None) is not None:
|
|
try:
|
|
await app.state.scheduled_task_service.stop()
|
|
except Exception:
|
|
logger.exception("Failed to stop scheduled task service")
|
|
|
|
try:
|
|
from deerflow.community.browser_automation import get_browser_session_manager
|
|
|
|
closed = await asyncio.wait_for(
|
|
get_browser_session_manager().close_all_sessions(),
|
|
timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
|
|
)
|
|
if closed:
|
|
logger.info("Closed %d browser session(s)", closed)
|
|
except TimeoutError:
|
|
logger.warning(
|
|
"Browser session shutdown exceeded %.1fs; proceeding with worker exit.",
|
|
_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
|
|
)
|
|
except Exception:
|
|
logger.exception("Failed to close browser sessions")
|
|
|
|
# Drain the memory backend's pending-update buffer before the worker
|
|
# exits (best-effort, bounded). IM channels and the scheduler are
|
|
# already stopped above, so no new IM/scheduler updates arrive during
|
|
# the drain; the LangGraph runtime / in-flight HTTP requests can still
|
|
# complete memory enqueues in a narrow window, but anything added after
|
|
# the drain copies the buffer only resets the debounce Timer
|
|
# (best-effort, same as today).
|
|
#
|
|
# No host-level pending/processing guard: ``shutdown_flush``
|
|
# short-circuits on a truly idle buffer (returns True immediately), so
|
|
# calling it unconditionally is cheap and keeps the in-flight-worker
|
|
# race entirely inside the backend (where the buffer lives) -- the host
|
|
# cannot "forget" that case the way a ``pending_count > 0``-only guard
|
|
# would (review #6 on the original PR).
|
|
#
|
|
# K8s caveat: ``shutdown_flush_timeout_seconds`` must fit inside the
|
|
# pod's ``terminationGracePeriodSeconds`` (channel stop + browser
|
|
# session close + this drain + buffer), set on the gateway Helm
|
|
# deployment -- or K8s SIGKILLs the drain mid-flight and the loss this
|
|
# is fixing is silently re-introduced.
|
|
try:
|
|
app_cfg = get_app_config()
|
|
if app_cfg.memory.enabled:
|
|
from deerflow.agents.memory import get_memory_manager
|
|
|
|
manager = get_memory_manager()
|
|
flush_timeout = app_cfg.memory.shutdown_flush_timeout_seconds
|
|
completed = await asyncio.to_thread(manager.shutdown_flush, flush_timeout)
|
|
if completed:
|
|
logger.info("Memory queue flush completed within %.1fs", flush_timeout)
|
|
else:
|
|
logger.warning(
|
|
"Memory queue flush did not finish within %.1fs; remaining updates may be lost",
|
|
flush_timeout,
|
|
)
|
|
except Exception:
|
|
logger.exception("Failed to flush memory queue on shutdown")
|
|
|
|
logger.info("Shutting down API Gateway")
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
"""Create and configure the FastAPI application.
|
|
|
|
Returns:
|
|
Configured FastAPI application instance.
|
|
"""
|
|
config = get_gateway_config()
|
|
docs_url = "/docs" if config.enable_docs else None
|
|
redoc_url = "/redoc" if config.enable_docs else None
|
|
openapi_url = "/openapi.json" if config.enable_docs else None
|
|
|
|
app = FastAPI(
|
|
title="DeerFlow API Gateway",
|
|
description="""
|
|
## DeerFlow API Gateway
|
|
|
|
API Gateway for DeerFlow - A LangGraph-based AI agent backend with sandbox execution capabilities.
|
|
|
|
### Features
|
|
|
|
- **Models Management**: Query and retrieve available AI models
|
|
- **MCP Configuration**: Manage Model Context Protocol (MCP) server configurations
|
|
- **Memory Management**: Access and manage global memory data for personalized conversations
|
|
- **Skills Management**: Query and manage skills and their enabled status
|
|
- **Artifacts**: Access thread artifacts and generated files
|
|
- **Health Monitoring**: System health check endpoints
|
|
|
|
### Architecture
|
|
|
|
LangGraph-compatible requests are routed through nginx to this gateway.
|
|
This gateway provides runtime endpoints for agent runs plus custom endpoints for models, MCP configuration, skills, and artifacts.
|
|
""",
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
docs_url=docs_url,
|
|
redoc_url=redoc_url,
|
|
openapi_url=openapi_url,
|
|
openapi_tags=[
|
|
{
|
|
"name": "models",
|
|
"description": "Operations for querying available AI models and their configurations",
|
|
},
|
|
{
|
|
"name": "mcp",
|
|
"description": "Manage Model Context Protocol (MCP) server configurations",
|
|
},
|
|
{
|
|
"name": "memory",
|
|
"description": "Access and manage global memory data for personalized conversations",
|
|
},
|
|
{
|
|
"name": "skills",
|
|
"description": "Manage skills and their configurations",
|
|
},
|
|
{
|
|
"name": "artifacts",
|
|
"description": "Access and download thread artifacts and generated files",
|
|
},
|
|
{
|
|
"name": "uploads",
|
|
"description": "Upload and manage user files for threads",
|
|
},
|
|
{
|
|
"name": "threads",
|
|
"description": "Manage DeerFlow thread-local filesystem data",
|
|
},
|
|
{
|
|
"name": "agents",
|
|
"description": "Create and manage custom agents with per-agent config and prompts",
|
|
},
|
|
{
|
|
"name": "suggestions",
|
|
"description": "Generate follow-up question suggestions for conversations",
|
|
},
|
|
{
|
|
"name": "input-polish",
|
|
"description": "Polish composer draft input before sending",
|
|
},
|
|
{
|
|
"name": "channels",
|
|
"description": "Manage IM channel integrations (Feishu, Slack, Telegram)",
|
|
},
|
|
{
|
|
"name": "assistants-compat",
|
|
"description": "LangGraph Platform-compatible assistants API (stub)",
|
|
},
|
|
{
|
|
"name": "runs",
|
|
"description": "LangGraph Platform-compatible runs lifecycle (create, stream, cancel)",
|
|
},
|
|
{
|
|
"name": "health",
|
|
"description": "Health check and system status endpoints",
|
|
},
|
|
],
|
|
)
|
|
|
|
# Auth: reject unauthenticated requests to non-public paths (fail-closed safety net)
|
|
app.add_middleware(AuthMiddleware)
|
|
|
|
# CSRF: Double Submit Cookie pattern for state-changing requests
|
|
app.add_middleware(CSRFMiddleware)
|
|
|
|
# CORS: the unified nginx endpoint is same-origin by default. Split-origin
|
|
# browser clients must opt in with this explicit Gateway allowlist so CORS
|
|
# and CSRF origin checks share the same source of truth.
|
|
cors_origins = sorted(get_configured_cors_origins())
|
|
if cors_origins:
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=cors_origins,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Request trace correlation: when logging.enhance.enabled=true, bind one
|
|
# trace id per Gateway HTTP request and write it to response start headers.
|
|
# `logging` is registered as restart-required (see reload_boundary.py) so we
|
|
# snapshot the flag from the startup AppConfig instead of reading live; a
|
|
# runtime toggle would otherwise leave the log formatter (installed once by
|
|
# configure_logging() at lifespan startup) out of sync with the middleware.
|
|
app.add_middleware(TraceMiddleware, enabled=_resolve_trace_enabled_for_app_construction())
|
|
|
|
# Include routers
|
|
# Models API is mounted at /api/models
|
|
app.include_router(models.router)
|
|
|
|
# Features API is mounted at /api/features
|
|
app.include_router(features.router)
|
|
|
|
# Console API (cross-thread observability) is mounted at /api/console
|
|
app.include_router(console.router)
|
|
|
|
# MCP API is mounted at /api/mcp
|
|
app.include_router(mcp.router)
|
|
|
|
# Memory API is mounted at /api/memory
|
|
app.include_router(memory.router)
|
|
|
|
# Skills API is mounted at /api/skills
|
|
app.include_router(skills.router)
|
|
|
|
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts
|
|
app.include_router(artifacts.router)
|
|
|
|
# Browser API is mounted at /api/threads/{thread_id}/browser
|
|
app.include_router(browser.router)
|
|
|
|
# Uploads API is mounted at /api/threads/{thread_id}/uploads
|
|
app.include_router(uploads.router)
|
|
|
|
# Thread cleanup API is mounted at /api/threads/{thread_id}
|
|
app.include_router(threads.router)
|
|
|
|
# Scheduled tasks API is mounted at /api/scheduled-tasks
|
|
app.include_router(scheduled_tasks.router)
|
|
|
|
# Agents API is mounted at /api/agents
|
|
app.include_router(agents.router)
|
|
|
|
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
|
|
app.include_router(suggestions.router)
|
|
|
|
# Input polishing API is mounted at /api/input-polish
|
|
app.include_router(input_polish.router)
|
|
|
|
# User-facing IM channel connection API is mounted at /api/channels
|
|
app.include_router(channel_connections.router)
|
|
|
|
# Channels API is mounted at /api/channels
|
|
app.include_router(channels.router)
|
|
|
|
# Assistants compatibility API (LangGraph Platform stub)
|
|
app.include_router(assistants_compat.router)
|
|
|
|
# Auth API is mounted at /api/v1/auth
|
|
app.include_router(auth.router)
|
|
|
|
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
|
|
app.include_router(feedback.router)
|
|
|
|
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
|
|
app.include_router(thread_runs.router)
|
|
|
|
# Stateless Runs API (stream/wait without a pre-existing thread)
|
|
app.include_router(runs.router)
|
|
|
|
# GitHub webhooks API is mounted at /api/webhooks/github
|
|
# Exempt from auth and CSRF middleware (see auth_middleware._PUBLIC_PATH_PREFIXES
|
|
# and csrf_middleware.should_check_csrf); authenticity is enforced via the
|
|
# X-Hub-Signature-256 HMAC against GITHUB_WEBHOOK_SECRET.
|
|
# Including this router transitively imports app.gateway.github, which
|
|
# registers the GitHub channel's ChannelRunPolicy as an import side-effect.
|
|
#
|
|
# Fail-closed: only mount the route when a webhook secret is configured
|
|
# (or when the explicit DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS=1
|
|
# dev opt-in is set). A misconfigured deployment without a secret cannot
|
|
# serve forged deliveries because the URL responds 404 — there is no
|
|
# handler to reach.
|
|
if github_webhooks.is_route_enabled():
|
|
app.include_router(github_webhooks.router)
|
|
logger.info("GitHub webhooks route mounted at /api/webhooks/github")
|
|
else:
|
|
logger.warning("GitHub webhooks route NOT mounted: GITHUB_WEBHOOK_SECRET unset and DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS not set. /api/webhooks/github will respond 404. Configure either env var to enable the route.")
|
|
|
|
@app.get("/health", tags=["health"])
|
|
async def health_check() -> dict[str, str]:
|
|
"""Health check endpoint.
|
|
|
|
Returns:
|
|
Service health status information.
|
|
"""
|
|
return {"status": "healthy", "service": "deer-flow-gateway"}
|
|
|
|
return app
|
|
|
|
|
|
def _resolve_trace_enabled_for_app_construction() -> bool:
|
|
"""Resolve the trace middleware flag without making imports require config.yaml."""
|
|
try:
|
|
return resolve_trace_enabled(get_app_config())
|
|
except FileNotFoundError:
|
|
# Startup lifespan still performs strict config loading before serving.
|
|
logger.debug("config.yaml not found while constructing Gateway app; TraceMiddleware disabled for this app instance")
|
|
return False
|
|
|
|
|
|
# Create app instance for uvicorn
|
|
app = create_app()
|