import asyncio import logging import os import sys from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from dotenv import load_dotenv from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware # Load .env BEFORE any module that reads os.environ at import time. # Without this, GATEWAY_CORS_ORIGINS / AUTH_JWT_SECRET / DEER_FLOW_AUTH_DISABLED # placed in /workspace/.env are silently ignored when the container is started # without explicit `-e` flags, leading to ephemeral JWT secrets, missing CORS # origins, and "Failed to fetch" in the browser. load_dotenv() from app.gateway.auth.disabled_mode import is_auth_disabled # noqa: E402 from app.gateway.auth_middleware import AuthMiddleware from app.gateway.config import get_gateway_config from app.gateway.csrf_middleware import CSRFMiddleware from app.gateway.deps import langgraph_runtime from app.gateway.routers import ( agents, artifacts, assistants_compat, auth, channels, feedback, llm_metrics, mcp, memory, models, notifications, recommended_questions, runs, scheduled_tasks, skills, suggestions, system_settings, tags, thread_runs, threads, uploads, ) from deerflow.config import app_config as deerflow_app_config from deerflow.config.app_config import apply_logging_level from deerflow.runtime.scheduler import ScheduledTaskService, set_scheduled_task_service 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="%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) 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 owner_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 _sync_legacy_skills(app: FastAPI) -> None: """Idempotently register existing on-disk ``skills/custom/*`` directories in the ``skills`` DB table, owned by the first admin and published=True. Without this step, after upgrading to per-user skill ownership the existing custom skill folders would not be visible to anyone (the SkillStore visibility filter relies on a DB row). Running this once on every boot is cheap (folder scan + one ``ensure_legacy`` per name) and idempotent. Non-fatal: a failure here MUST NOT block server startup. """ from sqlalchemy import select from deerflow.persistence.engine import get_session_factory from deerflow.persistence.user.model import UserRow from deerflow.skills.storage import get_or_new_skill_storage from deerflow.skills.types import SkillCategory sf = get_session_factory() if sf is None: return skill_store = getattr(app.state, "skill_store", None) if skill_store is None: return 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: # No admin yet — leave ownership null; first admin is responsible for # claiming via the UI once they exist. admin_id: str | None = None else: admin_id = str(row.id) config = getattr(app.state, "config", None) storage = get_or_new_skill_storage(app_config=config) try: skills = storage.load_skills(enabled_only=False) except Exception: logger.exception("_sync_legacy_skills: could not list on-disk skills") return inserted = 0 for skill in skills: if skill.category != SkillCategory.CUSTOM: continue try: existing = await skill_store.get_any(skill.name) if existing is not None: continue await skill_store.ensure_legacy( {"name": skill.name, "owner_user_id": admin_id, "published": True} ) inserted += 1 except Exception: logger.warning("_sync_legacy_skills: could not register %s", skill.name, exc_info=True) if inserted: logger.info("Skill ownership migration: registered %d legacy custom skill(s)", inserted) 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 async def _run_db_migrations() -> None: """Run Alembic migrations at startup so schema changes are applied automatically.""" import asyncio from pathlib import Path from alembic import command from alembic.config import Config import deerflow.persistence as _persistence_pkg from deerflow.persistence.engine import get_engine engine = get_engine() if engine is None: logger.info("Persistence backend=memory — skipping Alembic migrations") return db_url = str(engine.url) migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations") def _run_sync() -> None: cfg = Config() cfg.set_main_option("script_location", migrations_dir) cfg.set_main_option("sqlalchemy.url", db_url) command.upgrade(cfg, "head") try: await asyncio.get_running_loop().run_in_executor(None, _run_sync) logger.info("Database migrations applied (head)") except Exception: logger.exception("Database migration failed — schema may be out of date") # Skill curator 调度参数。 # - 检查间隔:每隔这么久检查一次"需不需要进化";真正是否执行由 should_run_now() 判定。 # - 启动延迟:启动后先等这么久再首次检查,避开启动高峰。 _CURATOR_CHECK_INTERVAL_SECONDS = 3600 # 1 小时 _CURATOR_STARTUP_DELAY_SECONDS = 300 # 5 分钟 def _acquire_singleton_lock(lock_path): """跨平台非阻塞独占文件锁,用于多 worker 下的 leader 选举。 成功 -> 返回持有锁的文件句柄(调用方须保持其存活,直到进程退出/主动释放); 抢不到锁(已有其它 worker 持有)或打开失败 -> 返回 None。 进程异常退出时 OS 会自动释放该锁。 """ try: lock_path.parent.mkdir(parents=True, exist_ok=True) handle = open(lock_path, "a+") except OSError: logger.exception("Curator scheduler: 打开锁文件失败") return None try: if sys.platform == "win32": import msvcrt handle.seek(0) msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) else: import fcntl fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: handle.close() return None return handle def _release_singleton_lock(handle) -> None: """释放并关闭 _acquire_singleton_lock 拿到的锁文件句柄。""" try: if sys.platform == "win32": import msvcrt handle.seek(0) msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) else: import fcntl fcntl.flock(handle.fileno(), fcntl.LOCK_UN) except OSError: pass finally: try: handle.close() except OSError: pass async def _curator_scheduler_loop() -> None: """常驻后台任务:每隔 _CURATOR_CHECK_INTERVAL_SECONDS 检查一次需不需要进化。 多 worker 部署下用文件锁做 leader 选举 —— 只有抢到锁的那个 worker 进程真正 运行调度,其余 worker 直接退出,避免 curator 被重复触发、重复调用 LLM。 被 cancel 时 CancelledError 会穿过 ``except Exception``(它是 BaseException 子类,不会被捕获),协程随之干净退出并在 finally 里释放锁。 """ from deerflow.config.runtime_paths import runtime_home from deerflow.skills.curator import maybe_run_curator lock_handle = _acquire_singleton_lock(runtime_home() / ".curator_scheduler.lock") if lock_handle is None: logger.info("Curator scheduler: 另一个 worker 已持有调度锁,本 worker 跳过") return logger.info("Curator scheduler: 取得调度锁,本 worker 负责 curator 调度") try: # 启动后延迟一会儿再首次检查,避开启动高峰 await asyncio.sleep(_CURATOR_STARTUP_DELAY_SECONDS) while True: try: await maybe_run_curator() except Exception: logger.exception("Curator scheduler tick failed (non-fatal)") await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS) finally: _release_singleton_lock(lock_handle) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan handler.""" # Load config and check necessary environment variables at startup try: app.state.config = get_app_config() apply_logging_level(app.state.config.log_level) logger.info("Configuration loaded successfully") 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}") # Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store) async with langgraph_runtime(app): logger.info("LangGraph runtime initialised") # Run Alembic migrations before anything else touches the schema. await _run_db_migrations() # Ensure admin user exists (auto-create on first boot) # Must run AFTER langgraph_runtime so app.state.store is available for thread migration await _ensure_admin_user(app) # Migrate "ownerless" on-disk skills into the new SkillStore table — # makes the multitenancy switch transparent for existing deployments. try: await _sync_legacy_skills(app) except Exception: logger.exception("Skill ownership migration failed (non-fatal)") # 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化 # (per-user interval_hours 仍是真正决定要不要跑进化的闸)。 try: app.state.curator_scheduler_task = asyncio.create_task(_curator_scheduler_loop()) except Exception: logger.exception("Skill curator scheduler failed to start (non-fatal)") # Start IM channel service if any channels are configured try: from app.channels.service import start_channel_service channel_service = await start_channel_service(app.state.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") scheduled_tasks_enabled = os.environ.get("SCHEDULED_TASKS_ENABLED", "true").strip().lower() if scheduled_tasks_enabled in {"0", "false", "no", "off"}: logger.info("Scheduled task service disabled by SCHEDULED_TASKS_ENABLED=%s", scheduled_tasks_enabled) else: try: scheduled_task_store = getattr(app.state, "scheduled_task_store", None) if scheduled_task_store is not None: scheduler_service = ScheduledTaskService(app, scheduled_task_store) app.state.scheduled_task_service = scheduler_service set_scheduled_task_service(scheduler_service) await scheduler_service.start() logger.info("Scheduled task service started") except Exception: logger.exception("Scheduled task service failed to start") yield # 停止 curator 调度器 curator_task = getattr(app.state, "curator_scheduler_task", None) if curator_task is not None: curator_task.cancel() try: await curator_task except asyncio.CancelledError: pass except Exception: logger.exception("Curator scheduler shutdown error") try: scheduler_service = getattr(app.state, "scheduled_task_service", None) if scheduler_service is not None: await scheduler_service.stop() except Exception: logger.exception("Failed to stop scheduled task service") finally: set_scheduled_task_service(None) # 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") 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_kwargs = {"docs_url": "/docs", "redoc_url": "/redoc", "openapi_url": "/openapi.json"} if config.enable_docs else {"docs_url": None, "redoc_url": None, "openapi_url": 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 requests are handled by nginx reverse proxy. This gateway provides custom endpoints for models, MCP configuration, skills, and artifacts. """, version="0.1.0", lifespan=lifespan, **docs_kwargs, 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": "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": "scheduled-tasks", "description": "Create, manage, and execute user scheduled agent tasks", }, { "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 if is_auth_disabled(): logger.warning( "DEER_FLOW_AUTH_DISABLED is set — API authentication is bypassed. " "Do not use this in production." ) def _cors_allow_any_origin() -> bool: """Mirror any browser Origin (works with credentials); cannot use literal '*'.""" v = os.environ.get("GATEWAY_CORS_ALLOW_ALL", "").strip().lower() return v in ("1", "true", "yes", "on") or is_auth_disabled() cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip() cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else [] # Headers the LangGraph SDK / browser clients need to read from cross-origin # responses. Without exposing them, the SDK's `response.headers.get(...)` returns # null on cross-origin streaming responses, breaking run-id extraction and reconnect. cors_expose_headers = [ "Content-Disposition", "Content-Location", "Location", "X-Run-Id", "X-Thread-Id", ] if _cors_allow_any_origin(): logger.warning( "Permissive CORS enabled — any Origin is echoed back (GATEWAY_CORS_ALLOW_ALL or " "DEER_FLOW_AUTH_DISABLED). Do not expose publicly." ) app.add_middleware( CORSMiddleware, allow_origins=[], # fullmatch() on the Origin header; browsers send scheme://host[:port] allow_origin_regex=r".+", allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=cors_expose_headers, ) elif cors_origins: if "*" in cors_origins: logger.error( "GATEWAY_CORS_ORIGINS contains '*' with allow_credentials=True — invalid. " "Set GATEWAY_CORS_ALLOW_ALL=1 instead, or list explicit origins." ) cors_origins = [o for o in cors_origins if o != "*"] if cors_origins: app.add_middleware( CORSMiddleware, allow_origins=cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=cors_expose_headers, ) else: # Safe-by-default fallback for local frontend development. Without any CORS # middleware, browser preflight OPTIONS requests will hit routes directly and # fail with 405, which blocks login and all JSON POST APIs. dev_origins = [ "http://localhost:5173", "http://127.0.0.1:5173", ] logger.warning( "No CORS configuration found; enabling local dev origins only: %s. " "Set GATEWAY_CORS_ORIGINS or GATEWAY_CORS_ALLOW_ALL for deployment.", ", ".join(dev_origins), ) app.add_middleware( CORSMiddleware, allow_origins=dev_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=cors_expose_headers, ) # Include routers # Models API is mounted at /api/models app.include_router(models.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) # 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) # Agents API is mounted at /api/agents app.include_router(agents.router) # Recommended Questions API is mounted at /api/recommended-questions app.include_router(recommended_questions.router) # Suggestions API is mounted at /api/threads/{thread_id}/suggestions app.include_router(suggestions.router) # Scheduled task API is mounted at /api/scheduled-tasks app.include_router(scheduled_tasks.router) # System settings API is mounted at /api/system-settings app.include_router(system_settings.router) # Admin LLM metrics audit + export app.include_router(llm_metrics.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) # Notifications API is mounted at /api/notifications app.include_router(notifications.router) # Tags API is mounted at /api/tags app.include_router(tags.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) @app.get("/health", tags=["health"]) async def health_check() -> dict: """Health check endpoint. Returns: Service health status information. """ return {"status": "healthy", "service": "deer-flow-gateway"} return app # Create app instance for uvicorn app = create_app()