SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
6.2 KB · 151 lines python
Raw Blame History
1"""FastAPI application — `/api/v1` (API 1.1). 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.etag import ETagMiddleware19from aiatlas.api.routers import (20    admin,21    admin_quality,22    benchmarks,23    changes,24    claims,25    companies,26    compare,27    deployments,28    diff,29    entities,30    explore,31    families,32    graph,33    hardware,34    intelligence,35    misc,36    models,37    papers,38    prices,39    providers,40    search,41    sources,42    stats,43    timeline,44)45from aiatlas.config import settings46from aiatlas.db import connection, dispose, fetch_val47from aiatlas.logging import setup_logging48from aiatlas.services import cache49from aiatlas.services.llm import gateway5051log = logging.getLogger("aiatlas.api")52API_VERSION = "1.1"535455@asynccontextmanager56async def lifespan(app: FastAPI):  # type: ignore[no-untyped-def]57    setup_logging(service="aia-api")58    settings.ensure_dirs()59    log.info("api started", extra={"version": aiatlas.__version__, "api": API_VERSION, "env": settings.app_env, "port": settings.api_port})60    yield61    await cache.close()62    await dispose()636465app = FastAPI(title="AI Atlas API", version=aiatlas.__version__, lifespan=lifespan, default_response_class=AtlasJSONResponse,66              docs_url="/api/v1/docs", redoc_url=None, openapi_url="/api/v1/openapi.json",67              description="The global intelligence layer for artificial intelligence: models, companies, papers, providers, prices, benchmarks, hardware — "68                          "with provenance and history on every fact. Contract: docs/API.md (API 1.1, additive over v1).")6970# Middleware order: the LAST added is the OUTERMOST. ETag must be inside GZip (hash of the uncompressed body), so it is added first.71app.add_middleware(ETagMiddleware)72app.add_middleware(GZipMiddleware, minimum_size=1024)73_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"}74app.add_middleware(CORSMiddleware, allow_origins=sorted(o for o in _origins if o), allow_methods=["GET", "POST", "PATCH", "OPTIONS"],75                   allow_headers=["*"], expose_headers=["etag", "cache-control", "x-api-version"], max_age=600)767778@app.middleware("http")79async def security_headers(request: Request, call_next):  # type: ignore[no-untyped-def]80    try:81        response = await call_next(request)82    except Exception:83        log.exception("unhandled error", extra={"route": request.url.path})84        return AtlasJSONResponse({"detail": "internal server error"}, status_code=500)85    response.headers["x-content-type-options"] = "nosniff"86    response.headers["referrer-policy"] = "strict-origin-when-cross-origin"87    response.headers["x-api-version"] = API_VERSION88    return response899091@app.exception_handler(StarletteHTTPException)92async def http_error(request: Request, exc: StarletteHTTPException):  # type: ignore[no-untyped-def]93    detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)94    return AtlasJSONResponse({"detail": detail}, status_code=exc.status_code, headers=dict(exc.headers or {}))959697@app.exception_handler(RequestValidationError)98async def validation_error(request: Request, exc: RequestValidationError):  # type: ignore[no-untyped-def]99    errs = exc.errors()[:5]100    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"101    return AtlasJSONResponse({"detail": msg, "errors": [{"loc": e.get("loc"), "msg": e.get("msg"), "type": e.get("type")} for e in errs]}, status_code=422)102103104@app.exception_handler(Exception)105async def unhandled(request: Request, exc: Exception):  # type: ignore[no-untyped-def]106    log.exception("unhandled error", extra={"route": request.url.path})107    return AtlasJSONResponse({"detail": "internal server error"}, status_code=500)108109110async def _health() -> dict[str, Any]:111    db_ok = redis_ok = False112    try:113        async with connection() as conn:114            db_ok = (await asyncio.wait_for(fetch_val(conn, "select 1"), timeout=3)) == 1115    except Exception:  # noqa: BLE001116        db_ok = False117    try:118        redis_ok = bool(await asyncio.wait_for(cache.redis().ping(), timeout=2))119    except Exception:  # noqa: BLE001120        redis_ok = False121    llm: dict[str, Any] = {"available": gateway.available}122    if gateway.available:123        cached = await cache.cache_get("health:llm")124        if cached is None:125            try:126                cached = {"reachable": bool(await asyncio.wait_for(gateway.engine.health(), timeout=2.5))}127            except Exception:  # noqa: BLE001128                cached = {"reachable": False}129            await cache.cache_set("health:llm", cached, 300)130        llm.update(cached)131    return {"status": "ok" if db_ok else "degraded", "version": aiatlas.__version__, "api_version": API_VERSION, "db": db_ok, "redis": redis_ok, "llm": llm, "time": datetime.now(UTC)}132133134@app.get("/health", tags=["health"])135async def health_root() -> dict[str, Any]:136    return await _health()137138139@app.get("/api/v1/health", tags=["health"])140async def health_v1() -> dict[str, Any]:141    return await _health()142143144# 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.145# `intelligence`, `families`, `graph`, `claims`, `deployments` and `misc` mount literal paths under /api/v1 and must precede `entities` (/{slug_or_id}).146for r in (stats, search, models, companies, papers, providers, prices, deployments, benchmarks, hardware, explore, changes, timeline, compare, diff, sources,147          intelligence, families, graph, claims, misc, entities, admin, admin_quality):148    app.include_router(r.router)149150__all__ = ["API_VERSION", "app"]151