SPB Git

spb/ai-atlas

Public
5.5 KB · 138 lines python
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
Files · 7456f4abc11ceb761f1f60861a5d96e6ddc93ef4
Menu

Theme