1"""FastAPI application — `/api/v1`. Loopback-only in production (Next.js proxies `/api/v1/*`); docs at `/api/v1/docs`."""2from __future__ import annotations34import asyncio5import logging6from contextlib import asynccontextmanager7from datetime import UTC, datetime8from typing import Any910from fastapi import FastAPI, Request11from fastapi.exceptions import RequestValidationError12from fastapi.middleware.cors import CORSMiddleware13from fastapi.middleware.gzip import GZipMiddleware14from starlette.exceptions import HTTPException as StarletteHTTPException1516import aiatlas17from aiatlas.api.common import AtlasJSONResponse18from aiatlas.api.routers import (19 admin,20 benchmarks,21 changes,22 companies,23 compare,24 diff,25 entities,26 explore,27 hardware,28 misc,29 models,30 papers,31 prices,32 providers,33 search,34 sources,35 stats,36 timeline,37)38from aiatlas.config import settings39from aiatlas.db import connection, dispose, fetch_val40from aiatlas.logging import setup_logging41from aiatlas.services import cache42from aiatlas.services.llm import gateway4344log = logging.getLogger("aiatlas.api")454647@asynccontextmanager48async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]49 setup_logging(service="aia-api")50 settings.ensure_dirs()51 log.info("api started", extra={"version": aiatlas.__version__, "env": settings.app_env, "port": settings.api_port})52 yield53 await cache.close()54 await dispose()555657app = FastAPI(title="AI Atlas API", version=aiatlas.__version__, lifespan=lifespan, default_response_class=AtlasJSONResponse,58 docs_url="/api/v1/docs", redoc_url=None, openapi_url="/api/v1/openapi.json",59 description="The global intelligence layer for artificial intelligence: models, companies, papers, providers, prices, benchmarks, hardware — "60 "with provenance and history on every fact. Contract: docs/API.md.")6162app.add_middleware(GZipMiddleware, minimum_size=1024)63_origins = {settings.site_url, "https://www.ai-atlas.co", "https://ai-atlas.co", "http://localhost:8320", "http://127.0.0.1:8320", "http://localhost:8330", "http://127.0.0.1:8330"}64app.add_middleware(CORSMiddleware, allow_origins=sorted(o for o in _origins if o), allow_methods=["GET", "POST", "PATCH", "OPTIONS"],65 allow_headers=["*"], max_age=600)666768@app.middleware("http")69async def security_headers(request: Request, call_next): # type: ignore[no-untyped-def]70 try:71 response = await call_next(request)72 except Exception:73 log.exception("unhandled error", extra={"route": request.url.path})74 return AtlasJSONResponse({"detail": "internal server error"}, status_code=500)75 response.headers["x-content-type-options"] = "nosniff"76 response.headers["referrer-policy"] = "strict-origin-when-cross-origin"77 return response787980@app.exception_handler(StarletteHTTPException)81async def http_error(request: Request, exc: StarletteHTTPException): # type: ignore[no-untyped-def]82 detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)83 return AtlasJSONResponse({"detail": detail}, status_code=exc.status_code, headers=dict(exc.headers or {}))848586@app.exception_handler(RequestValidationError)87async def validation_error(request: Request, exc: RequestValidationError): # type: ignore[no-untyped-def]88 errs = exc.errors()[:5]89 msg = "; ".join(f"{'.'.join(str(x) for x in e.get('loc', []) if x not in ('query', 'body', 'path'))}: {e.get('msg')}" for e in errs) or "invalid request"90 return AtlasJSONResponse({"detail": msg, "errors": [{"loc": e.get("loc"), "msg": e.get("msg"), "type": e.get("type")} for e in errs]}, status_code=422)919293@app.exception_handler(Exception)94async def unhandled(request: Request, exc: Exception): # type: ignore[no-untyped-def]95 log.exception("unhandled error", extra={"route": request.url.path})96 return AtlasJSONResponse({"detail": "internal server error"}, status_code=500)979899async def _health() -> dict[str, Any]:100 db_ok = redis_ok = False101 try:102 async with connection() as conn:103 db_ok = (await asyncio.wait_for(fetch_val(conn, "select 1"), timeout=3)) == 1104 except Exception: # noqa: BLE001105 db_ok = False106 try:107 redis_ok = bool(await asyncio.wait_for(cache.redis().ping(), timeout=2))108 except Exception: # noqa: BLE001109 redis_ok = False110 llm: dict[str, Any] = {"available": gateway.available}111 if gateway.available:112 cached = await cache.cache_get("health:llm")113 if cached is None:114 try:115 cached = {"reachable": bool(await asyncio.wait_for(gateway.engine.health(), timeout=2.5))}116 except Exception: # noqa: BLE001117 cached = {"reachable": False}118 await cache.cache_set("health:llm", cached, 300)119 llm.update(cached)120 return {"status": "ok" if db_ok else "degraded", "version": aiatlas.__version__, "db": db_ok, "redis": redis_ok, "llm": llm, "time": datetime.now(UTC)}121122123@app.get("/health", tags=["health"])124async def health_root() -> dict[str, Any]:125 return await _health()126127128@app.get("/api/v1/health", tags=["health"])129async def health_v1() -> dict[str, Any]:130 return await _health()131132133# Order matters only where a literal path and a `{param}` path share a prefix — literal routes live in the same router and are declared first.134for r in (stats, search, models, companies, papers, providers, prices, benchmarks, hardware, explore, changes, timeline, compare, diff, sources, misc, entities, admin):135 app.include_router(r.router)136137__all__ = ["app"]138