File size: 15,067 Bytes
0703e2b | 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 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 | """HTTP service around an ``FGAORAG`` instance (FastAPI).
Endpoints
GET /health liveness
GET /ready readiness
GET /v1/info version, sources, costs, budget, controller configuration
POST /v1/answer {"question", "budget"?, "seed"?, "trace"?, "documents"?, "web_documents"?}
-> RAGResult as JSON; with "documents", FGAO answers from those passages
POST /v1/answer/stream same body; Server-Sent Events: "step" per decision, then "result"
POST /v1/batch {"questions": [...], ...} -> {"results": [...]}
GET /metrics Prometheus metrics
GET /docs, /openapi.json interactive API docs
Operational behaviour
* Admission control: at most ``max_inflight`` queries run at once; up to
``max_queue`` more wait; beyond that the server answers 429 with Retry-After.
* Each query has a deadline (``request_timeout`` s): 504 on expiry, and the
in-flight model call is cancelled.
* A failing dependency (after retries) answers 502 with the error type,
unless the pipeline was built with ``on_error="record"``.
* Optional API keys: ``Authorization: Bearer <key>`` or ``X-API-Key: <key>``.
* Every response carries ``X-Request-ID`` (echoed if the client sent one), and
one JSON log line per query is written to the ``fgao.server`` logger.
"""
from __future__ import annotations
import asyncio
import dataclasses
import json
import logging
import time
import uuid
from typing import Any, Dict, Iterable, List, Optional, Union
from .. import __version__
from ..rag.pipeline import FGAORAG, RAGResult
from ..resilience import _Resilient
from .metrics import Registry
from fastapi import Depends, FastAPI, Header, HTTPException, Request # noqa: E402
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse # noqa: E402
from pydantic import BaseModel, Field # noqa: E402
log = logging.getLogger("fgao.server")
DocIn = Union[str, Dict[str, Any]]
class AnswerRequest(BaseModel):
question: str = Field(..., min_length=1, max_length=8000)
budget: Optional[float] = Field(None, gt=0)
seed: int = 0
trace: bool = True
documents: Optional[List[DocIn]] = Field(None, description="answer from these passages instead of the configured KB")
web_documents: Optional[List[DocIn]] = Field(None, description="optional second source for the request documents")
class BatchRequest(BaseModel):
questions: List[str] = Field(..., min_length=1)
budget: Optional[float] = Field(None, gt=0)
seed: int = 0
trace: bool = False
documents: Optional[List[DocIn]] = None
web_documents: Optional[List[DocIn]] = None
class Overloaded(Exception):
pass
class Admission:
"""Bounded concurrency with a bounded wait queue (per event loop / worker process)."""
def __init__(self, max_inflight: int, max_queue: int) -> None:
self.max_inflight, self.max_queue = max_inflight, max_queue
self.inflight = 0
self.waiting = 0
self._sem: Optional[asyncio.Semaphore] = None
def _s(self) -> asyncio.Semaphore:
if self._sem is None:
self._sem = asyncio.Semaphore(self.max_inflight)
return self._sem
def check(self, n: int = 1) -> None:
free = self.max_inflight - self.inflight
if n > free and self.waiting + n - max(free, 0) > self.max_queue:
raise Overloaded()
async def acquire(self) -> None:
self.check()
self.waiting += 1
try:
await self._s().acquire()
finally:
self.waiting -= 1
self.inflight += 1
def release(self) -> None:
self.inflight -= 1
self._s().release()
def _resilient_parts(rag: FGAORAG) -> List[_Resilient]:
seen, out = set(), []
cands = [rag.llm] + [s.retriever for s in rag.sources]
for comp in (rag.generator, rag.grader, rag.rewriter, rag.support_checker):
cands.append(getattr(comp, "llm", None))
for c in cands:
if isinstance(c, _Resilient) and id(c) not in seen:
seen.add(id(c))
out.append(c)
return out
def create_app(
rag: FGAORAG,
*,
api_keys: Optional[Iterable[str]] = None,
max_inflight: int = 256,
max_queue: int = 1024,
request_timeout: float = 60.0,
max_batch: int = 256,
max_budget: Optional[float] = None,
max_documents: int = 500,
title: str = "FGAO RAG",
):
"""Build the FastAPI application. ``max_budget`` caps per-request budgets (default: the pipeline budget x 4)."""
keys = {k for k in (api_keys or []) if k}
cap = float(max_budget if max_budget is not None else rag.budget * 4)
adm = Admission(max_inflight, max_queue)
reg = Registry()
m_http = reg.counter("fgao_http_requests_total", "HTTP requests by route and status code")
m_lat = reg.histogram("fgao_request_duration_seconds", "Query latency in seconds",
(0.05, 0.1, 0.25, 0.5, 1, 2, 4, 8, 16, 32, 64))
m_out = reg.counter("fgao_queries_total", "Queries by outcome (verified, abstained, timeout, error)")
m_stop = reg.counter("fgao_stop_status_total", "Queries by FGAO stop status")
m_cost = reg.histogram("fgao_query_cost", "Budget spent per query", (1, 2, 4, 6, 8, 10, 12, 16, 24, 32, 64))
m_ops = reg.counter("fgao_operation_calls_total", "Operation calls (generate, grade, rewrite, verify, retrieve:*)")
m_steps = reg.counter("fgao_steps_total", "Controller steps executed")
m_inflight = reg.gauge("fgao_inflight", "Queries running")
m_queue = reg.gauge("fgao_queue", "Queries waiting for a slot")
parts = _resilient_parts(rag)
def collect():
m_inflight.set(adm.inflight)
m_queue.set(adm.waiting)
for p in parts:
for k, v in p.stats.as_dict().items():
yield (f"fgao_dependency_{k}_total", "counter", f"Dependency {k} (resilient wrappers)", {"name": p.name}, v)
reg.collectors.append(lambda: list(collect()))
app = FastAPI(title=title, version=__version__,
description="Training-free FGAO orchestration of retrieval-augmented generation.")
def auth(authorization: Optional[str] = Header(None), x_api_key: Optional[str] = Header(None)) -> None:
if not keys:
return
token = x_api_key or (authorization[7:] if authorization and authorization.lower().startswith("bearer ") else None)
if token not in keys:
raise HTTPException(status_code=401, detail="invalid or missing API key")
@app.middleware("http")
async def request_id(request: Request, call_next):
rid = request.headers.get("x-request-id") or uuid.uuid4().hex
request.state.rid = rid
resp = await call_next(request)
resp.headers["X-Request-ID"] = rid
route = request.scope.get("route")
m_http.inc(route=getattr(route, "path", request.url.path), code=str(resp.status_code))
return resp
def _budget(b: Optional[float]) -> Optional[float]:
if b is not None and b > cap:
raise HTTPException(status_code=422, detail=f"budget exceeds the server limit {cap:g}")
return b
def _payload(r: RAGResult, trace: bool) -> Dict[str, Any]:
d = r.to_dict()
if not trace:
d.pop("trace", None)
return d
def _record(r: RAGResult, seconds: float, rid: str) -> None:
m_out.inc(outcome="verified" if r.answer is not None else "abstained")
m_stop.inc(status=r.status)
m_lat.observe(seconds)
m_cost.observe(r.cost)
m_steps.inc(len(r.steps))
for k, v in r.calls.items():
m_ops.inc(v, op=k)
log.info(json.dumps({"event": "query", "request_id": rid, "status": r.status, "verified": r.answer is not None,
"cost": r.cost, "steps": len(r.steps), "seconds": round(seconds, 4)}))
def _pipeline(documents, web_documents) -> FGAORAG:
"""The configured pipeline, or one over the request's own passages (same LLM, costs, priors, controller)."""
if not documents:
return rag
if len(documents) > max_documents or (web_documents and len(web_documents) > max_documents):
raise HTTPException(status_code=422, detail=f"at most {max_documents} documents per source")
if rag.llm is None:
raise HTTPException(status_code=422, detail="request documents need a pipeline with a default LLM")
return FGAORAG.from_retrievers(kb=list(documents), web=list(web_documents) if web_documents else None,
llm=rag.llm, costs=rag.costs, priors=rag.priors, weights=rag.weights,
budget=rag.budget, config=rag.config, on_error=rag.on_error)
async def _run(q: str, budget: Optional[float], seed: int, rid: str, pipe: Optional[FGAORAG] = None) -> RAGResult:
t0 = time.perf_counter()
pipe = pipe or rag
try:
r = await asyncio.wait_for(pipe.ainvoke(q, budget=budget, seed=seed), request_timeout)
except asyncio.TimeoutError:
m_out.inc(outcome="timeout")
m_lat.observe(time.perf_counter() - t0)
log.warning(json.dumps({"event": "timeout", "request_id": rid}))
raise
except Exception as e: # noqa: BLE001
m_out.inc(outcome="error")
m_lat.observe(time.perf_counter() - t0)
log.error(json.dumps({"event": "error", "request_id": rid, "error": type(e).__name__, "detail": str(e)[:500]}))
raise
_record(r, time.perf_counter() - t0, rid)
return r
def _overloaded() -> JSONResponse:
return JSONResponse({"detail": "server overloaded, retry later"}, status_code=429, headers={"Retry-After": "1"})
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/ready")
async def ready():
return {"status": "ready", "inflight": adm.inflight, "queued": adm.waiting}
@app.get("/v1/info", dependencies=[Depends(auth)])
async def info():
return {
"version": __version__,
"budget": rag.budget, "max_budget": cap,
"sources": [{"name": s.name, "k": s.k, "cost": s.cost, "family": s.family, "rewrite": s.rewrite}
for s in rag.sources],
"costs": dataclasses.asdict(rag.costs),
"weights": dataclasses.asdict(rag.weights),
"priors": dataclasses.asdict(rag.priors),
"controller": dataclasses.asdict(rag.config),
"operations": [str(a) for a in rag.app.actions],
"on_error": rag.on_error,
"limits": {"max_inflight": max_inflight, "max_queue": max_queue, "request_timeout": request_timeout,
"max_batch": max_batch},
}
@app.post("/v1/answer", dependencies=[Depends(auth)])
async def answer(req: AnswerRequest, request: Request):
budget = _budget(req.budget)
pipe = _pipeline(req.documents, req.web_documents)
try:
await adm.acquire()
except Overloaded:
return _overloaded()
try:
r = await _run(req.question, budget, req.seed, request.state.rid, pipe)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail=f"query exceeded {request_timeout:g} s")
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"{type(e).__name__}: {e}"[:500])
finally:
adm.release()
return _payload(r, req.trace)
@app.post("/v1/batch", dependencies=[Depends(auth)])
async def batch(req: BatchRequest, request: Request):
if len(req.questions) > max_batch:
raise HTTPException(status_code=422, detail=f"at most {max_batch} questions per batch")
budget = _budget(req.budget)
pipe = _pipeline(req.documents, req.web_documents)
try:
adm.check(len(req.questions))
except Overloaded:
return _overloaded()
async def one(i: int, q: str) -> Dict[str, Any]:
await adm.acquire() # admitted above; this only waits for a slot
try:
return _payload(await _run(q, budget, req.seed, f"{request.state.rid}:{i}", pipe), req.trace)
except asyncio.TimeoutError:
return {"question": q, "error": "timeout"}
except Exception as e: # noqa: BLE001
return {"question": q, "error": f"{type(e).__name__}: {e}"[:500]}
finally:
adm.release()
results = await asyncio.gather(*(one(i, q) for i, q in enumerate(req.questions)))
return {"results": list(results)}
@app.post("/v1/answer/stream", dependencies=[Depends(auth)])
async def stream(req: AnswerRequest, request: Request):
budget = _budget(req.budget)
pipe = _pipeline(req.documents, req.web_documents)
try:
await adm.acquire()
except Overloaded:
return _overloaded()
rid = request.state.rid
released = [False]
def release_once() -> None:
if not released[0]:
released[0] = True
adm.release()
async def events():
t0 = time.perf_counter()
agen = pipe.astream(req.question, budget=budget, seed=req.seed).__aiter__()
try:
while True:
left = request_timeout - (time.perf_counter() - t0)
try:
item = await asyncio.wait_for(agen.__anext__(), max(left, 0.001))
except StopAsyncIteration:
break
if isinstance(item, RAGResult):
_record(item, time.perf_counter() - t0, rid)
yield f"event: result\ndata: {json.dumps(_payload(item, req.trace))}\n\n"
else:
yield f"event: step\ndata: {json.dumps(item.to_dict())}\n\n"
except asyncio.TimeoutError:
m_out.inc(outcome="timeout")
yield f"event: error\ndata: {json.dumps({'error': 'timeout'})}\n\n"
except Exception as e: # noqa: BLE001
m_out.inc(outcome="error")
yield f"event: error\ndata: {json.dumps({'error': f'{type(e).__name__}: {e}'[:500]})}\n\n"
finally:
release_once()
from starlette.background import BackgroundTask
return StreamingResponse(events(), media_type="text/event-stream", background=BackgroundTask(release_once),
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
@app.get("/metrics")
async def metrics():
return PlainTextResponse(reg.render(), media_type="text/plain; version=0.0.4")
app.state.rag = rag
app.state.admission = adm
app.state.registry = reg
return app
|