Spaces:
Sleeping
Sleeping
File size: 9,976 Bytes
9f8362f a4508e8 392b662 9f8362f a4508e8 392b662 a4508e8 9f8362f a4508e8 9f8362f a4508e8 9f8362f a4508e8 9f8362f a4508e8 0623ff0 a4508e8 9f8362f a4508e8 9f8362f 392b662 a4508e8 9f8362f a4508e8 9f8362f a4508e8 0623ff0 a4508e8 0623ff0 a4508e8 0623ff0 a4508e8 9f8362f a4508e8 9f8362f b4a2e6b 9f8362f a4508e8 b4a2e6b a4508e8 9f8362f a4508e8 9f8362f a4508e8 9f8362f a4508e8 3c35b12 a4508e8 9f8362f a4508e8 9f8362f a4508e8 9f8362f a4508e8 9f8362f a4508e8 9f8362f a4508e8 9f8362f a4508e8 3c35b12 a4508e8 3c35b12 a4508e8 9f8362f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 | """GPU Perf Prophet FastAPI app: GET /health, /version, /gpus, /models; POST /predict, /predict/batch, /recommend."""
from __future__ import annotations
import logging
import os
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
from pydantic import Field
from src.api.middleware import RateLimiter, log_access, new_request_id
from src.api.schemas import (
PredictRequest,
PredictResponse,
RecommendRequest,
RecommendResponse,
VersionOut,
)
from src.data.gpu_spec_db import load_specs, spec_db_version
from src.models.predictor import GpuPredictor, VALID_MODELS
from src.recommend.recommender import GpuRecommender
# Hard cap on raw body size to block parsing-based DoS before Pydantic validation runs; 50 requests ≈ 10 KB, so 1 MB is a generous ceiling.
_MAX_BODY_BYTES = 1 * 1024 * 1024 # 1 MB
# Without this, the root logger has no handler and every log.info() call (including the pre-existing "Loading model…" lifespan lines) is silently dropped — uvicorn only configures its own "uvicorn"/"uvicorn.access"/"uvicorn.error" loggers, never root; format="%(message)s" keeps the access log's JSON lines bare and directly log-shippable.
logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger(__name__)
_predictor: GpuPredictor | None = None
_recommender: GpuRecommender | None = None
_gpu_list: list[dict] | None = None
_model_list: list[str] = sorted(VALID_MODELS)
_gpu_spec_db_version: str = "unknown"
# state_path opts into surviving a supervisord crash-restart (docker/supervisord.conf) — without it, an autorestart silently resets every client's rate-limit history.
_rate_limiter = RateLimiter(
state_path=Path(os.environ.get("GPP_RATE_LIMITER_STATE_PATH", "/tmp/gpp_rate_limiter_state.json"))
)
@asynccontextmanager
async def lifespan(app: FastAPI):
global _predictor, _recommender, _gpu_list, _gpu_spec_db_version
log.info("Loading model …")
_predictor = GpuPredictor()
_recommender = GpuRecommender(_predictor)
specs = load_specs()
_gpu_spec_db_version = spec_db_version()
_gpu_list = [
{
"id": s["id"],
"name": s.get("name", s["id"]),
"vendor": s.get("vendor"),
"architecture": s.get("architecture"),
"vram_gb": s.get("vram_gb"),
"in_model_scope": s.get("in_model_scope", False),
}
for s in specs
]
log.info("Ready.")
yield
_predictor = None
_recommender = None
_gpu_list = None
app = FastAPI(
title="GPU Perf Prophet",
description=(
"Cross-vendor LLM inference performance forecasting "
"and workload-aware GPU recommendation."
),
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST"],
allow_headers=["Content-Type"],
)
@app.middleware("http")
async def limit_body_size(request: Request, call_next) -> Response:
# Fast path: reject via Content-Length if present; a missing/malformed header isn't an error, it just means checking the actual body below.
cl = request.headers.get("content-length")
if cl is not None:
try:
if int(cl) > _MAX_BODY_BYTES:
return Response(status_code=413, content="Request body too large")
except ValueError:
return Response(status_code=400, content="Invalid Content-Length header")
# GET/HEAD/OPTIONS carry no request body, so buffering here is wasted work.
if request.method in {"GET", "HEAD", "OPTIONS"}:
return await call_next(request)
# Slow path: consume the body so chunked-transfer clients (no Content-Length) can't bypass the check — uvicorn enforces no default body size cap.
body = await request.body()
if len(body) > _MAX_BODY_BYTES:
return Response(status_code=413, content="Request body too large")
# Re-inject the consumed body so downstream route handlers can read it.
async def _receive() -> dict:
return {"type": "http.request", "body": body, "more_body": False}
request._receive = _receive
return await call_next(request)
# Registered after limit_body_size (so it wraps it — the outermost middleware runs first on the way in and last on the way out) to keep request-id/rate-limit/access-log applying to every response, including 413s from the body-size guard above.
@app.middleware("http")
async def observability(request: Request, call_next) -> Response:
request_id = new_request_id()
request.state.request_id = request_id
client_ip = request.client.host if request.client else "unknown"
if not _rate_limiter.allow(client_ip):
response = Response(status_code=429, content="Rate limit exceeded")
response.headers["X-Request-ID"] = request_id
log_access(request_id, request, 429, 0.0)
return response
start = time.monotonic()
response = await call_next(request)
latency_ms = (time.monotonic() - start) * 1000
response.headers["X-Request-ID"] = request_id
log_access(request_id, request, response.status_code, latency_ms)
return response
def _get_predictor() -> GpuPredictor:
if _predictor is None:
raise HTTPException(status_code=503, detail="Model not loaded")
return _predictor
def _get_recommender() -> GpuRecommender:
if _recommender is None:
raise HTTPException(status_code=503, detail="Recommender not loaded")
return _recommender
def _provenance_fields() -> dict:
"""The 4 fields VersionOut and MetaOut share (everything but MetaOut's request_id) — the single source /version, /health, and _build_meta() all read from, so a future provenance field only needs adding in one place instead of several independently-maintained copies."""
predictor = _get_predictor()
return {
"model_artifact_version": predictor.model_version,
"model_artifact_sha256": predictor.model_artifact_sha256,
"pricing_snapshot_date": _recommender.pricing_source_date if _recommender else None,
"gpu_spec_db_version": _gpu_spec_db_version,
}
def _build_meta(request: Request) -> dict:
return {"request_id": request.state.request_id, **_provenance_fields()}
@app.exception_handler(HTTPException)
async def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
# meta needs a loaded predictor to compute its fields; the 503 "not loaded" case is the one HTTPException path where that data genuinely doesn't exist yet, so it's omitted there rather than faked. Every other HTTPException here (422s from `except ValueError` in predict/predict_batch/recommend) only happens after the predictor is already loaded. Pydantic's own RequestValidationError (e.g. a bad accuracy_tier Literal) is a separate exception type this handler does not cover.
body: dict = {"detail": exc.detail}
if _predictor is not None:
body["meta"] = _build_meta(request)
return JSONResponse(status_code=exc.status_code, content=body, headers=exc.headers)
# Routes
@app.get("/health")
def health() -> dict:
body: dict = {"status": "ok", "model_loaded": _predictor is not None}
# _provenance_fields() raises 503 via _get_predictor() when unset, but a health check must never itself throw on the exact "not ready yet" state it exists to report.
if _predictor is not None:
body.update(_provenance_fields())
return body
@app.get("/version", response_model=VersionOut)
def version() -> dict:
return _provenance_fields()
@app.get("/gpus")
def list_gpus() -> dict:
return {"gpus": _gpu_list or []}
@app.get("/models")
def list_models() -> dict:
return {"models": _model_list}
@app.post("/predict", response_model=PredictResponse)
def predict(req: PredictRequest, request: Request) -> dict:
predictor = _get_predictor()
try:
result = predictor.predict(
gpu_id=req.gpu_id,
model_name=req.model_name,
scenario=req.scenario,
accuracy_tier=req.accuracy_tier,
framework=req.framework,
batch_size=req.batch_size,
input_tokens=req.input_tokens,
output_tokens=req.output_tokens,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
result["meta"] = _build_meta(request)
return result
@app.post("/predict/batch", response_model=list[PredictResponse])
def predict_batch(
requests: Annotated[list[PredictRequest], Field(max_length=50)],
request: Request,
) -> list[dict]:
predictor = _get_predictor()
try:
results = predictor.predict_batch([r.model_dump() for r in requests])
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
meta = _build_meta(request)
for result in results:
result["meta"] = meta
return results
@app.post("/recommend", response_model=RecommendResponse)
def recommend(req: RecommendRequest, request: Request) -> dict:
recommender = _get_recommender()
try:
result = recommender.recommend(
model_name=req.model_name,
scenario=req.scenario,
accuracy_tier=req.accuracy_tier,
framework=req.framework,
batch_size=req.batch_size,
input_tokens=req.input_tokens,
output_tokens=req.output_tokens,
budget_per_gpu_hr=req.budget_per_gpu_hr,
min_throughput_tok_per_sec=req.min_throughput_tok_per_sec,
ranking_objective=req.ranking_objective,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
result["meta"] = _build_meta(request)
return result
|