e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
454 lines
15 KiB
Python
454 lines
15 KiB
Python
from contextlib import asynccontextmanager
|
|
import logging
|
|
import sys
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from deeptutor.logging import configure_logging
|
|
from deeptutor.services.config import (
|
|
ensure_runtime_settings_files,
|
|
export_runtime_settings_to_env,
|
|
load_auth_settings,
|
|
load_system_settings,
|
|
)
|
|
from deeptutor.services.config.origins import normalize_origins
|
|
from deeptutor.services.path_service import get_path_service
|
|
|
|
ensure_runtime_settings_files()
|
|
export_runtime_settings_to_env(overwrite=True)
|
|
configure_logging()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class _SuppressWsNoise(logging.Filter):
|
|
"""Suppress noisy uvicorn logs for WebSocket connection churn."""
|
|
|
|
_SUPPRESSED = ("connection open", "connection closed")
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
msg = record.getMessage()
|
|
return not any(f in msg for f in self._SUPPRESSED)
|
|
|
|
|
|
logging.getLogger("uvicorn.error").addFilter(_SuppressWsNoise())
|
|
|
|
CONFIG_DRIFT_ERROR_TEMPLATE = (
|
|
"Configuration Drift Detected: Capability tool references {drift} are not "
|
|
"registered in the runtime tool registry. Register the missing tools or "
|
|
"remove the stale tool names from the capability manifests."
|
|
)
|
|
|
|
|
|
class SafeOutputStaticFiles(StaticFiles):
|
|
"""Static file mount that only exposes explicitly whitelisted artifacts."""
|
|
|
|
def __init__(self, *args, path_service, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self._path_service = path_service
|
|
|
|
async def get_response(self, path: str, scope):
|
|
if not self._path_service.is_public_output_path(path):
|
|
raise HTTPException(status_code=404, detail="Output not found")
|
|
return await super().get_response(path, scope)
|
|
|
|
|
|
def validate_tool_consistency():
|
|
"""
|
|
Validate that capability manifests only reference tools that are actually
|
|
registered in the runtime ``ToolRegistry``.
|
|
"""
|
|
try:
|
|
from deeptutor.runtime.registry.capability_registry import get_capability_registry
|
|
from deeptutor.runtime.registry.tool_registry import get_tool_registry
|
|
|
|
capability_registry = get_capability_registry()
|
|
tool_registry = get_tool_registry()
|
|
available_tools = set(tool_registry.list_tools())
|
|
|
|
referenced_tools = set()
|
|
for manifest in capability_registry.get_manifests():
|
|
referenced_tools.update(manifest.get("tools_used", []) or [])
|
|
|
|
drift = referenced_tools - available_tools
|
|
if drift:
|
|
raise RuntimeError(CONFIG_DRIFT_ERROR_TEMPLATE.format(drift=drift))
|
|
except RuntimeError:
|
|
logger.exception("Configuration validation failed")
|
|
raise
|
|
except Exception:
|
|
logger.exception("Failed to load configuration for validation")
|
|
raise
|
|
|
|
|
|
def _build_cors_settings() -> dict[str, object]:
|
|
"""Build CORS settings for both localhost and remote Docker deployments."""
|
|
system_settings = load_system_settings()
|
|
auth_settings = load_auth_settings()
|
|
frontend_port = str(system_settings["frontend_port"])
|
|
extra_origins = normalize_origins(
|
|
[system_settings["cors_origin"], system_settings["cors_origins"]]
|
|
)
|
|
origins = [
|
|
f"http://localhost:{frontend_port}",
|
|
f"http://127.0.0.1:{frontend_port}",
|
|
"http://localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
]
|
|
for origin in extra_origins:
|
|
if origin not in origins:
|
|
origins.append(origin)
|
|
|
|
# Auth is disabled by default. In that local/single-user mode, mirror the
|
|
# pre-v1.3.8 behavior and allow remote Docker/LAN origins out of the box.
|
|
# When auth is enabled, require explicit CORS_ORIGIN(S) for credentialed
|
|
# cross-origin requests.
|
|
allow_origin_regex = None if auth_settings["enabled"] else r"https?://.*"
|
|
mode = "explicit" if auth_settings["enabled"] else "permissive"
|
|
return {
|
|
"allow_origins": origins,
|
|
"allow_origin_regex": allow_origin_regex,
|
|
"mode": mode,
|
|
}
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""
|
|
Application lifecycle management
|
|
Gracefully handle startup and shutdown events, avoid CancelledError
|
|
"""
|
|
# Execute on startup
|
|
logger.info("Application startup")
|
|
|
|
# Validate configuration consistency
|
|
validate_tool_consistency()
|
|
|
|
# Initialize LLM client early so OPENAI_* env vars are available before
|
|
# any downstream provider integrations start.
|
|
try:
|
|
from deeptutor.services.llm import get_llm_client
|
|
|
|
llm_client = get_llm_client()
|
|
logger.info(f"LLM client initialized: model={llm_client.config.model}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to initialize LLM client at startup: {e}")
|
|
|
|
try:
|
|
from deeptutor.events.event_bus import get_event_bus
|
|
|
|
event_bus = get_event_bus()
|
|
await event_bus.start()
|
|
logger.info("EventBus started")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to start EventBus: {e}")
|
|
|
|
try:
|
|
from deeptutor.services.partners import get_partner_manager
|
|
|
|
await get_partner_manager().auto_start_partners()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to auto-start partners: {e}")
|
|
|
|
try:
|
|
from deeptutor.services.cron import get_cron_service
|
|
|
|
await get_cron_service().start()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to start cron service: {e}")
|
|
|
|
# Ping PocketBase if configured — logs a warning (not an error) if unreachable
|
|
try:
|
|
from deeptutor.services.pocketbase_client import ping_pocketbase
|
|
|
|
await ping_pocketbase()
|
|
except Exception as e:
|
|
logger.warning(f"PocketBase startup check failed: {e}")
|
|
|
|
# Migrate any v1 memory files (PROFILE.md / SUMMARY.md) into a
|
|
# backup folder so the v2 three-layer subsystem starts clean.
|
|
try:
|
|
from deeptutor.services.memory import (
|
|
migrate_partner_surface_if_needed,
|
|
migrate_v1_if_needed,
|
|
)
|
|
|
|
backup = migrate_v1_if_needed()
|
|
if backup is not None:
|
|
logger.info("v1 memory archived to %s", backup)
|
|
# Rename the legacy ``tutorbot`` memory surface (footnote refs, L2
|
|
# doc, snapshot/trace dirs, L3 meta keys) to ``partner``.
|
|
migrate_partner_surface_if_needed()
|
|
except Exception as e:
|
|
logger.warning(f"v1 memory migration failed: {e}")
|
|
|
|
yield
|
|
|
|
# Execute on shutdown
|
|
logger.info("Application shutdown")
|
|
|
|
# Stop cron scheduler
|
|
try:
|
|
from deeptutor.services.cron import get_cron_service
|
|
|
|
await get_cron_service().stop()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to stop cron service: {e}")
|
|
|
|
# Stop partners
|
|
try:
|
|
from deeptutor.services.partners import get_partner_manager
|
|
|
|
await get_partner_manager().stop_all(preserve_auto_start=True)
|
|
logger.info("Partners stopped")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to stop partners: {e}")
|
|
|
|
# Stop EventBus
|
|
try:
|
|
from deeptutor.events.event_bus import get_event_bus
|
|
|
|
event_bus = get_event_bus()
|
|
await event_bus.stop()
|
|
logger.info("EventBus stopped")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to stop EventBus: {e}")
|
|
|
|
|
|
app = FastAPI(
|
|
title="DeepTutor API",
|
|
version="1.0.0",
|
|
lifespan=lifespan,
|
|
# Disable automatic trailing slash redirects to prevent protocol downgrade issues
|
|
# when deployed behind HTTPS reverse proxies (e.g., nginx).
|
|
# Without this, FastAPI's 307 redirects may change HTTPS to HTTP.
|
|
# See: https://github.com/HKUDS/DeepTutor/issues/112
|
|
redirect_slashes=False,
|
|
)
|
|
|
|
# Access logging is funneled through this one middleware. uvicorn's own
|
|
# per-request access log is disabled on every launch path (run_server.py via
|
|
# access_log=False; the launcher and Docker via `--no-access-log`), so routine
|
|
# 200s — the chatty frontend polling of /settings, /tools, /knowledge/list,
|
|
# etc. — never reach the logs. Only non-200s are surfaced, since those are the
|
|
# ones worth seeing.
|
|
#
|
|
# The `deeptutor.access` logger gets its own INFO stdout handler rather than
|
|
# leaning on the root handlers: the root console handler runs at the global log
|
|
# level (WARNING by default), which would swallow these INFO access lines.
|
|
# propagate=False keeps them from also printing through root if the global
|
|
# level is ever lowered to INFO/DEBUG.
|
|
_access_logger = logging.getLogger("deeptutor.access")
|
|
if not any(getattr(h, "_deeptutor_access_handler", False) for h in _access_logger.handlers):
|
|
_access_handler = logging.StreamHandler(sys.stdout)
|
|
_access_handler.setLevel(logging.INFO)
|
|
_access_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
_access_handler._deeptutor_access_handler = True # type: ignore[attr-defined]
|
|
_access_logger.addHandler(_access_handler)
|
|
_access_logger.setLevel(logging.INFO)
|
|
_access_logger.propagate = False
|
|
|
|
|
|
@app.middleware("http")
|
|
async def selective_access_log(request, call_next):
|
|
response = await call_next(request)
|
|
if response.status_code != 200:
|
|
_access_logger.info(
|
|
'%s - "%s %s HTTP/%s" %d',
|
|
request.client.host if request.client else "-",
|
|
request.method,
|
|
request.url.path,
|
|
request.scope.get("http_version", "1.1"),
|
|
response.status_code,
|
|
)
|
|
return response
|
|
|
|
|
|
_cors_settings = _build_cors_settings()
|
|
logger.info(
|
|
"CORS configured: mode=%s allow_origins=%s allow_origin_regex=%s",
|
|
_cors_settings["mode"],
|
|
_cors_settings["allow_origins"],
|
|
_cors_settings["allow_origin_regex"],
|
|
)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=_cors_settings["allow_origins"],
|
|
allow_origin_regex=_cors_settings["allow_origin_regex"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Mount a filtered view over user outputs.
|
|
# Only whitelisted artifact paths are readable through the static handler.
|
|
path_service = get_path_service()
|
|
user_dir = path_service.get_public_outputs_root()
|
|
|
|
# Initialize user directories on startup
|
|
try:
|
|
from deeptutor.services.setup import init_user_directories
|
|
|
|
init_user_directories()
|
|
except Exception:
|
|
# Fallback: just create the main directory if it doesn't exist
|
|
if not user_dir.exists():
|
|
user_dir.mkdir(parents=True)
|
|
|
|
app.mount(
|
|
"/api/outputs",
|
|
SafeOutputStaticFiles(directory=str(user_dir), path_service=path_service),
|
|
name="outputs",
|
|
)
|
|
|
|
# Import routers only after runtime settings are initialized.
|
|
# Some router modules load YAML settings at import time.
|
|
from deeptutor.api.routers import (
|
|
agent_config,
|
|
attachments,
|
|
auth,
|
|
book,
|
|
capabilities_settings,
|
|
chat,
|
|
co_writer,
|
|
dashboard,
|
|
imports,
|
|
knowledge,
|
|
mastery_path,
|
|
mcp_settings,
|
|
memory,
|
|
notebook,
|
|
partners,
|
|
personas,
|
|
plugins_api,
|
|
question,
|
|
question_notebook,
|
|
quiz_judge,
|
|
sessions,
|
|
settings,
|
|
skills,
|
|
subagents,
|
|
system,
|
|
unified_ws,
|
|
voice,
|
|
)
|
|
from deeptutor.api.routers import (
|
|
tools as tools_router,
|
|
)
|
|
from deeptutor.multi_user.router import router as multi_user_router # noqa: E402
|
|
|
|
# Auth router is public — login/logout/register/status require no token
|
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
|
|
|
# All other routers require a valid session when AUTH_ENABLED=true.
|
|
# require_auth is a no-op when AUTH_ENABLED=false, so this is safe for local use.
|
|
from deeptutor.api.routers.auth import require_admin, require_auth # noqa: E402
|
|
|
|
_auth = [Depends(require_auth)]
|
|
# Partner data is anchored at the admin workspace (data/partners) and shared
|
|
# process-wide, so management is admin-gated in multi-user deployments
|
|
# (single-user local runs are implicitly admin — no behaviour change there).
|
|
_admin = [Depends(require_admin)]
|
|
|
|
app.include_router(
|
|
multi_user_router,
|
|
prefix="/api/v1/multi-user",
|
|
tags=["multi-user"],
|
|
dependencies=_auth,
|
|
)
|
|
|
|
app.include_router(chat.router, prefix="/api/v1", tags=["chat"], dependencies=_auth)
|
|
app.include_router(
|
|
question.router, prefix="/api/v1/question", tags=["question"], dependencies=_auth
|
|
)
|
|
app.include_router(
|
|
knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"], dependencies=_auth
|
|
)
|
|
app.include_router(imports.router, prefix="/api/v1/imports", tags=["imports"], dependencies=_auth)
|
|
app.include_router(
|
|
dashboard.router, prefix="/api/v1/dashboard", tags=["dashboard"], dependencies=_auth
|
|
)
|
|
app.include_router(
|
|
mastery_path.router,
|
|
prefix="/api/v1/learning",
|
|
tags=["mastery-path"],
|
|
dependencies=_auth,
|
|
)
|
|
app.include_router(
|
|
co_writer.router, prefix="/api/v1/co_writer", tags=["co_writer"], dependencies=_auth
|
|
)
|
|
app.include_router(
|
|
notebook.router, prefix="/api/v1/notebook", tags=["notebook"], dependencies=_auth
|
|
)
|
|
app.include_router(book.router, prefix="/api/v1/book", tags=["book"], dependencies=_auth)
|
|
app.include_router(memory.router, prefix="/api/v1/memory", tags=["memory"], dependencies=_auth)
|
|
app.include_router(
|
|
capabilities_settings.router,
|
|
prefix="/api/v1/capabilities",
|
|
tags=["capabilities"],
|
|
dependencies=_auth,
|
|
)
|
|
app.include_router(
|
|
sessions.router, prefix="/api/v1/sessions", tags=["sessions"], dependencies=_auth
|
|
)
|
|
app.include_router(
|
|
question_notebook.router,
|
|
prefix="/api/v1/question-notebook",
|
|
tags=["question-notebook"],
|
|
dependencies=_auth,
|
|
)
|
|
app.include_router(
|
|
settings.router, prefix="/api/v1/settings", tags=["settings"], dependencies=_auth
|
|
)
|
|
app.include_router(
|
|
mcp_settings.router,
|
|
prefix="/api/v1/settings/mcp",
|
|
tags=["mcp-settings"],
|
|
dependencies=_auth,
|
|
)
|
|
app.include_router(skills.router, prefix="/api/v1/skills", tags=["skills"], dependencies=_auth)
|
|
app.include_router(
|
|
subagents.router, prefix="/api/v1/subagents", tags=["subagents"], dependencies=_auth
|
|
)
|
|
app.include_router(
|
|
personas.router, prefix="/api/v1/personas", tags=["personas"], dependencies=_auth
|
|
)
|
|
app.include_router(tools_router.router, prefix="/api/v1/tools", tags=["tools"], dependencies=_auth)
|
|
app.include_router(system.router, prefix="/api/v1/system", tags=["system"], dependencies=_auth)
|
|
app.include_router(voice.router, prefix="/api/v1/voice", tags=["voice"], dependencies=_auth)
|
|
app.include_router(
|
|
plugins_api.router, prefix="/api/v1/plugins", tags=["plugins"], dependencies=_auth
|
|
)
|
|
app.include_router(
|
|
agent_config.router, prefix="/api/v1/agent-config", tags=["agent-config"], dependencies=_auth
|
|
)
|
|
app.include_router(
|
|
partners.router, prefix="/api/v1/partners", tags=["partners"], dependencies=_admin
|
|
)
|
|
app.include_router(
|
|
attachments.router,
|
|
prefix="/api/attachments",
|
|
tags=["attachments"],
|
|
dependencies=_auth,
|
|
)
|
|
|
|
# Unified WebSocket endpoint — auth is checked inside the handler (WebSockets
|
|
# cannot use FastAPI dependencies in the standard way)
|
|
app.include_router(unified_ws.router, prefix="/api/v1", tags=["unified-ws"])
|
|
|
|
# Quiz AI-judge WebSocket — same caveat as unified_ws above; auth is checked
|
|
# inside the handler so the WS upgrade isn't rejected by an HTTP-style dep.
|
|
app.include_router(quiz_judge.router, prefix="/api/v1", tags=["quiz-judge"])
|
|
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
return {"message": "Welcome to DeepTutor API"}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
from deeptutor.api.run_server import main as run_server_main
|
|
|
|
run_server_main()
|