system-agent / app /main.py
BrejBala's picture
The System - grounded portfolio agent with hardened guardrails
6907e87 verified
Raw
History Blame Contribute Delete
4.11 kB
"""The System — grounded portfolio Q&A agent.
A deliberately small FastAPI proxy with guardrails:
- POST /chat {"messages":[{"role","content"},...]} -> {"reply": "..."}
- GET /health
Security model: the browser never sees an API key; keys live in HF Space
secrets and are read from the environment. Client input is untrusted data —
validated, sanitized, delimited, rate-limited, and never allowed to override
the server-side system prompt. Interactive docs are disabled to keep the
surface minimal.
"""
from __future__ import annotations
import logging
import os
import time
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .guard import ChatRequest, ChatResponse, RateLimiter, client_ip, wrap_user_content
from .llm import AllProvidersFailed, active_provider, generate_reply
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("system-agent")
# Comma-separated exact origins; no wildcard, ever.
ALLOWED_ORIGINS = [
o.strip()
for o in os.environ.get(
"ALLOWED_ORIGINS",
"https://brej-29.github.io,http://localhost:5500,http://localhost:8321",
).split(",")
if o.strip()
]
MAX_BODY_BYTES = 16_384 # 12 msgs x 500 chars leaves ample headroom
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_methods=["POST", "OPTIONS"],
allow_headers=["Content-Type"],
max_age=3600,
)
limiter = RateLimiter()
@app.middleware("http")
async def hardening(request: Request, call_next):
started = time.monotonic()
# Reject oversized payloads before parsing.
length = request.headers.get("content-length")
if length and length.isdigit() and int(length) > MAX_BODY_BYTES:
response = JSONResponse(status_code=413, content={"error": "payload too large"})
else:
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
response.headers["Referrer-Policy"] = "no-referrer"
# Privacy-respecting access log: no message content, no full IPs.
log.info(
"method=%s path=%s status=%s latency_ms=%d",
request.method,
request.url.path,
response.status_code,
(time.monotonic() - started) * 1000,
)
return response
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
# Uniform contract: every failure body is {"error": "..."}; details are
# intentionally generic so probing reveals nothing about internals.
return JSONResponse(status_code=422, content={"error": "invalid request format"})
@app.exception_handler(Exception)
async def unhandled_handler(request: Request, exc: Exception):
log.error("unhandled error: %s", type(exc).__name__)
return JSONResponse(status_code=500, content={"error": "internal error"})
@app.get("/health")
async def health():
return {"status": "ok", "provider": active_provider()}
@app.post("/chat", response_model=ChatResponse)
async def chat(request: Request, payload: ChatRequest):
ip = client_ip(request.headers, request.client.host if request.client else "unknown")
allowed, reason = limiter.check(ip)
if not allowed:
return JSONResponse(status_code=429, content={"error": reason})
# Sanitize + delimit every user turn; assistant turns are echoes of our own
# prior output but get sanitized anyway (defense in depth).
messages = [
{
"role": m.role,
"content": wrap_user_content(m.content) if m.role == "user" else m.content[:1600],
}
for m in payload.messages
]
try:
reply = await generate_reply(messages)
except AllProvidersFailed:
return JSONResponse(status_code=503, content={"error": "SYSTEM LINK UNSTABLE"})
return ChatResponse(reply=reply)