dakshtaneja's picture
health: report whether image search actually works; don't eat $ as math
e4b6db2
Raw
History Blame Contribute Delete
4.67 kB
"""FastAPI app exposing the AuctionRouter pipeline."""
from contextlib import asynccontextmanager
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
from fastapi import Depends, FastAPI, HTTPException, Request # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from fastapi.staticfiles import StaticFiles # noqa: E402
import json # noqa: E402
from fastapi.responses import StreamingResponse # noqa: E402
from slowapi import _rate_limit_exceeded_handler # noqa: E402
from slowapi.errors import RateLimitExceeded # noqa: E402
from .config import TIER1_MODELS, TIER2_MODEL, VERIFIER_MODEL, settings # noqa: E402
from .llm import close_client # noqa: E402
from .pipeline import run_query, run_query_stream # noqa: E402
from .schemas import MetricsSummary, QueryRequest, RunResult # noqa: E402
from .security import RATE_LIMITS, limiter, require_access, spend_guard # noqa: E402
from .store import get_store # noqa: E402
from . import websearch # noqa: E402
from .websearch import close_client as close_search_client # noqa: E402
@asynccontextmanager
async def lifespan(app: FastAPI):
# Probe the image search once at boot so a bad TAVILY_API_KEY shows up in
# /health immediately, rather than as silently missing images. Backgrounded
# so a slow or hanging Tavily can't delay readiness.
import asyncio
probe = asyncio.ensure_future(websearch.verify())
yield
probe.cancel()
await close_client()
await close_search_client()
app = FastAPI(title="AuctionRouter", version="0.1.0", lifespan=lifespan)
# Per-IP rate limiting (slowapi)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Browser-origin allowlist. NOT a security boundary (curl ignores CORS) —
# the access code + rate limits + spend guard are. Just lets the deployed
# frontend call the API from its own origin.
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.allowed_origins.split(",") if o.strip()],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health():
# Open (no access code): HF Spaces healthcheck. Returns no secrets.
return {
"status": "ok",
"openrouter_key_set": bool(settings.openrouter_api_key),
"access_required": bool(settings.access_code),
# Not just "is a key set" — the real state of the last call, so a
# deployment with an unusable key can't look healthy.
"image_search": websearch.status(),
"store": "mongodb" if settings.mongodb_uri else "memory",
"tier1_models": [m.openrouter_id for m in TIER1_MODELS.values()],
"verifier": VERIFIER_MODEL.openrouter_id,
"tier2_model": TIER2_MODEL.openrouter_id,
}
@app.post("/api/query", response_model=RunResult,
dependencies=[Depends(require_access)])
@limiter.limit(RATE_LIMITS)
async def query(request: Request, req: QueryRequest):
if not settings.openrouter_api_key:
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY is not set")
spend_guard.check()
return await run_query(req.query, [t.model_dump() for t in req.history],
req.hint)
@app.post("/api/query/stream", dependencies=[Depends(require_access)])
@limiter.limit(RATE_LIMITS)
async def query_stream(request: Request, req: QueryRequest):
if not settings.openrouter_api_key:
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY is not set")
spend_guard.check()
history = [t.model_dump() for t in req.history]
async def gen():
try:
async for event in run_query_stream(req.query, history, req.hint):
yield json.dumps(event) + "\n"
except Exception as e: # surface pipeline crashes to the client
yield json.dumps({"type": "error", "message": str(e)[:300]}) + "\n"
return StreamingResponse(gen(), media_type="application/x-ndjson")
@app.get("/api/runs", response_model=list[RunResult],
dependencies=[Depends(require_access)])
async def runs(limit: int = 50):
return await get_store().list_runs(min(limit, 200))
@app.get("/api/metrics", response_model=MetricsSummary,
dependencies=[Depends(require_access)])
async def metrics():
return await get_store().metrics()
# In the Hugging Face Space image the Next.js static export is copied to
# ./static and served from the same origin as the API.
_static_dir = Path(__file__).resolve().parent.parent / "static"
if _static_dir.is_dir():
app.mount("/", StaticFiles(directory=_static_dir, html=True), name="static")