"""FastAPI application factory for FusionAGI Dvādaśa API. Includes versioned API negotiation, metrics, and CORS support.""" from __future__ import annotations import json import os import time from collections import defaultdict from contextlib import asynccontextmanager from typing import Any from fusionagi._logger import logger from fusionagi.api.dependencies import SessionStore, default_orchestrator, set_app_state from fusionagi.api.metrics import get_metrics, metrics_enabled API_VERSION = "1" SUPPORTED_VERSIONS = ["1"] DEPRECATED_VERSIONS: list[str] = [] def create_app( adapter: Any = None, cors_origins: list[str] | None = None, ) -> Any: """Create FastAPI app with Dvādaśa routes. Args: adapter: Optional LLMAdapter for head/Witness LLM calls. cors_origins: Optional list of CORS allowed origins. """ try: from fastapi import FastAPI, Request, Response from starlette.middleware.base import BaseHTTPMiddleware except ImportError as e: raise ImportError("Install with: pip install fusionagi[api]") from e # --- Lifespan (replaces deprecated on_event) --- @asynccontextmanager async def lifespan(application: FastAPI): # type: ignore[type-arg] """Startup / shutdown lifecycle.""" adapter_inner = getattr(application.state, "llm_adapter", None) orch, bus = default_orchestrator(adapter_inner) store = SessionStore() set_app_state(orch, bus, store) application.state._dvadasa_ready = True logger.info("FusionAGI Dvādaśa API started") yield logger.info("FusionAGI Dvādaśa API shutdown") app = FastAPI( title="FusionAGI Dvādaśa API", description=( "12-headed multi-agent orchestration API.\n\n" "## Authentication\n" "Set `FUSIONAGI_API_KEY` to require Bearer token auth on all `/v1/` routes.\n\n" "## Rate Limiting\n" "Default: 120 requests/minute per client IP. " "Configure via `FUSIONAGI_RATE_LIMIT` (requests) and " "`FUSIONAGI_RATE_WINDOW` (seconds) env vars." ), version="0.1.0", lifespan=lifespan, ) app.state.llm_adapter = adapter from fusionagi.api.dependencies import set_default_adapter set_default_adapter(adapter) # --- Auth middleware --- api_key = os.environ.get("FUSIONAGI_API_KEY") class AuthMiddleware(BaseHTTPMiddleware): """Bearer token authentication for /v1/ routes.""" async def dispatch(self, request: Request, call_next: Any) -> Response: if api_key and request.url.path.startswith("/v1/"): auth = request.headers.get("authorization", "") if not auth.startswith("Bearer ") or auth[7:].strip() != api_key: return Response( content='{"detail":"Invalid or missing API key"}', status_code=401, media_type="application/json", ) return await call_next(request) # type: ignore[no-any-return] app.add_middleware(AuthMiddleware) # --- Rate limiting middleware --- rate_limit = int(os.environ.get("FUSIONAGI_RATE_LIMIT", "120")) rate_window = float(os.environ.get("FUSIONAGI_RATE_WINDOW", "60")) _buckets: dict[str, list[float]] = defaultdict(list) class RateLimitMiddleware(BaseHTTPMiddleware): """Per-tenant + per-IP sliding window rate limiter (advisory mode). Tracks both IP-level and tenant-level request rates. Logs exceedances but allows requests through (advisory governance). """ async def dispatch(self, request: Request, call_next: Any) -> Response: client_ip = request.client.host if request.client else "unknown" tenant_id = request.headers.get("x-tenant-id", "default") now = time.monotonic() cutoff = now - rate_window # Per-IP tracking ip_key = f"ip:{client_ip}" _buckets[ip_key] = [t for t in _buckets[ip_key] if t > cutoff] if len(_buckets[ip_key]) >= rate_limit: logger.info( "API rate limit advisory: IP limit exceeded (proceeding)", extra={"client_ip": client_ip, "count": len(_buckets[ip_key]), "limit": rate_limit}, ) # Per-tenant tracking (separate quota) tenant_key = f"tenant:{tenant_id}" tenant_limit = rate_limit * 5 # tenants get 5x the per-IP limit _buckets[tenant_key] = [t for t in _buckets[tenant_key] if t > cutoff] if len(_buckets[tenant_key]) >= tenant_limit: logger.info( "API rate limit advisory: tenant limit exceeded (proceeding)", extra={"tenant_id": tenant_id, "count": len(_buckets[tenant_key]), "limit": tenant_limit}, ) _buckets[ip_key].append(now) _buckets[tenant_key].append(now) return await call_next(request) # type: ignore[no-any-return] app.add_middleware(RateLimitMiddleware) # --- Version negotiation middleware --- class VersionMiddleware(BaseHTTPMiddleware): """API version negotiation via Accept-Version header. Adds X-API-Version and deprecation warnings to responses. """ async def dispatch(self, request: Request, call_next: Any) -> Response: requested = request.headers.get("accept-version", API_VERSION) if requested not in SUPPORTED_VERSIONS: return Response( content=json.dumps({ "detail": f"Unsupported API version: {requested}", "supported_versions": SUPPORTED_VERSIONS, }), status_code=400, media_type="application/json", ) response = await call_next(request) response.headers["X-API-Version"] = requested if requested in DEPRECATED_VERSIONS: response.headers["Deprecation"] = "true" response.headers["Sunset"] = "2026-12-31" return response # type: ignore[no-any-return] app.add_middleware(VersionMiddleware) # --- Metrics middleware --- if metrics_enabled(): class MetricsMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Any) -> Response: m = get_metrics() m.inc("http_requests_total", labels={"method": request.method, "path": request.url.path}) start = time.monotonic() response = await call_next(request) duration = time.monotonic() - start m.observe("http_request_duration_seconds", duration, labels={"path": request.url.path}) m.inc("http_responses_total", labels={"status": str(response.status_code)}) return response # type: ignore[no-any-return] app.add_middleware(MetricsMiddleware) # --- Routes --- from fusionagi.api.routes import router as api_router app.include_router(api_router, prefix="/v1", tags=["dvadasa"]) # Metrics endpoint if metrics_enabled(): @app.get("/metrics", tags=["monitoring"]) def metrics_endpoint() -> dict[str, Any]: return get_metrics().snapshot() # Health check endpoints (no auth required) _start_time = time.time() @app.get("/health", tags=["monitoring"]) def health_check() -> dict[str, Any]: """Basic health check for load balancer probes.""" return {"status": "healthy", "uptime_seconds": round(time.time() - _start_time, 1)} @app.get("/ready", tags=["monitoring"]) def readiness_check() -> dict[str, Any]: """Readiness probe. Returns 503 if not initialized.""" ready = getattr(app.state, "_dvadasa_ready", False) if not ready: from starlette.responses import JSONResponse return JSONResponse( # type: ignore[return-value] content={"status": "not_ready"}, status_code=503, ) return {"status": "ready", "uptime_seconds": round(time.time() - _start_time, 1)} # Version info endpoint @app.get("/version", tags=["meta"]) def version_info() -> dict[str, Any]: return { "current_version": API_VERSION, "supported_versions": SUPPORTED_VERSIONS, "deprecated_versions": DEPRECATED_VERSIONS, } if cors_origins is not None: try: from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=cors_origins, allow_methods=["*"], allow_headers=["*"], ) except ImportError: pass return app # Default app instance for uvicorn/gunicorn app = create_app()