| """ |
| Tiny server for the speech-to-speech demo. |
| |
| The demo used to ship as a `sdk: static` Space, but the web-search tool needs a |
| search key the browser must NOT see. A static Space has no runtime process, so it |
| can't hold a secret the front-end uses. This server fixes that: it serves the |
| unchanged front-end AND exposes a same-origin `/api/search` proxy that holds the |
| Serper key server-side (see docs/adr/0001). |
| |
| Everything lives in one container; the speech-to-speech backend stays a separate, |
| load-balanced service the browser talks to over WebSocket as before. The load |
| balancer's address is a secret too (like the Serper key): the browser never sees |
| it. `/api/session` proxies the session handshake server-side so only the |
| per-session compute URL the LB hands back (which the browser must dial) is exposed. |
| |
| On the deployed Space the server also meters conversation time by HF login tier |
| (anonymous / signed-in / PRO) — see `limiter.py` and `auth.py`. That whole feature |
| is off unless BOTH `LOAD_BALANCER_URL` and `SPACE_ID` are set, so it runs only on |
| the live Space, never locally (even with the LB exported for testing). |
| |
| Endpoints: |
| GET /api/config -> { search, lb, allowDirect, auth } |
| GET /api/me -> login + tier + remaining budget (LB mode only) |
| POST /api/search -> { results, answer } Google via Serper.dev |
| POST /api/session -> proxies <LB>/session: a grant, or a queue ticket |
| GET /api/queue/{id} -> proxies <LB>/queue/{id}: position, or a grant on claim |
| DELETE /api/queue/{id} -> leave the queue (explicit "Leave queue" button) |
| POST /api/queue/end -> leave the queue (sendBeacon on teardown) |
| POST /api/session/heartbeat-> extend the reservation; { expired } |
| POST /api/session/end -> reconcile + refund (sendBeacon on teardown) |
| /* -> static files (index.html, main.js, ...) |
| |
| When every compute slot is busy the load balancer hands back a queue ticket |
| instead of a grant; the browser polls /api/queue/{id} until it reaches the front |
| and a slot frees. Waiting reserves nothing — the daily budget is only reserved at |
| the moment a slot is actually claimed (a grant), never while queued. |
| """ |
|
|
| import asyncio |
| import logging |
| import os |
|
|
| import httpx |
| from fastapi import FastAPI, HTTPException, Request |
| from fastapi.responses import JSONResponse |
| from fastapi.staticfiles import StaticFiles |
| from pydantic import BaseModel |
|
|
| import auth |
| import limiter |
|
|
| logger = logging.getLogger("s2s.search") |
|
|
| SERPER_KEY = os.environ.get("SERPER_API_KEY", "").strip() |
| |
| |
| |
| |
| |
| LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip() |
| |
| |
| |
| |
| |
| SPACE_ID = os.environ.get("SPACE_ID", "").strip() |
| LIMITER_ENABLED = bool(LOAD_BALANCER_URL) and bool(SPACE_ID) |
| SERPER_URL = "https://google.serper.dev/search" |
| |
| MAX_RESULTS = 5 |
| HERE = os.path.dirname(os.path.abspath(__file__)) |
|
|
| app = FastAPI(title="s2s-demo") |
|
|
| |
| |
| AUTH_ENABLED = LIMITER_ENABLED and auth.attach(app) |
|
|
|
|
| @app.on_event("startup") |
| async def _startup(): |
| """Stand up the usage DB and a periodic sweeper — metered (prod Space) only.""" |
| if not LIMITER_ENABLED: |
| return |
| limiter.init() |
| asyncio.create_task(_sweeper()) |
|
|
|
|
| async def _sweeper(): |
| while True: |
| await asyncio.sleep(limiter.REAP_AFTER_SEC) |
| try: |
| await asyncio.to_thread(limiter.sweep) |
| except Exception as exc: |
| logger.warning("usage sweep failed: %r", exc) |
|
|
|
|
| class SearchRequest(BaseModel): |
| query: str |
| |
| |
| key: str | None = None |
|
|
|
|
| @app.get("/api/config") |
| def config(): |
| """Client bootstrap: whether web search is available, whether the deploy runs |
| behind a load balancer (so the browser uses the /api/session proxy + limiter), |
| whether HF sign-in is available, and whether the user may instead set a direct |
| s2s server URL. The LB address itself is intentionally NOT included.""" |
| return { |
| "search": bool(SERPER_KEY), |
| "lb": bool(LOAD_BALANCER_URL), |
| "allowDirect": not LOAD_BALANCER_URL, |
| "auth": AUTH_ENABLED, |
| } |
|
|
|
|
| @app.get("/api/me") |
| async def me(request: Request): |
| """Login state, tier, and remaining daily budget. Only meaningful in LB mode; |
| sets the anonymous tracking cookie when first seen.""" |
| if not LIMITER_ENABLED: |
| return {"enabled": False} |
| view = auth.user_view(request) |
| tier, keys, set_cookie = auth.resolve_identity(request) |
| unlimited = limiter.budget_for(tier) is None |
| rem = None if unlimited else await asyncio.to_thread(limiter.remaining, keys, tier) |
| out = { |
| "enabled": True, |
| "auth": AUTH_ENABLED, |
| **view, |
| "remainingSec": rem, |
| "limitSec": limiter.budget_for(tier), |
| "loginUrl": auth.OAUTH_LOGIN_PATH if AUTH_ENABLED else None, |
| "logoutUrl": auth.OAUTH_LOGOUT_PATH if AUTH_ENABLED else None, |
| } |
| resp = JSONResponse(out) |
| if set_cookie: |
| auth.set_anon_cookie(resp, set_cookie) |
| return resp |
|
|
|
|
| @app.post("/api/search") |
| async def search(req: SearchRequest): |
| """Proxy a Google search via Serper.dev. The key stays on the server unless |
| the user brought their own (then theirs is used for this request only).""" |
| query = (req.query or "").strip() |
| if not query: |
| raise HTTPException(status_code=400, detail="Empty query.") |
|
|
| key = (req.key or "").strip() or SERPER_KEY |
| if not key: |
| |
| raise HTTPException(status_code=503, detail="Search is not configured.") |
|
|
| headers = {"X-API-KEY": key, "Content-Type": "application/json"} |
| payload = {"q": query, "num": MAX_RESULTS} |
| try: |
| async with httpx.AsyncClient(timeout=12.0) as http: |
| resp = await http.post(SERPER_URL, headers=headers, json=payload) |
| except httpx.RequestError as exc: |
| logger.warning("Serper unreachable: %r", exc) |
| raise HTTPException(status_code=502, detail="Search provider unreachable.") |
|
|
| if resp.status_code != 200: |
| |
| |
| body = resp.text[:300] |
| logger.warning("Serper error %s: %s", resp.status_code, body) |
| msg = None |
| try: |
| msg = resp.json().get("message") |
| except Exception: |
| pass |
| detail = f"Search provider error ({resp.status_code})" |
| if msg: |
| detail += f": {msg}" |
| raise HTTPException(status_code=502, detail=detail) |
|
|
| data = resp.json() |
| results = [] |
| for item in (data.get("organic") or [])[:MAX_RESULTS]: |
| results.append( |
| { |
| "title": item.get("title", ""), |
| "snippet": item.get("snippet", ""), |
| "url": item.get("link", ""), |
| } |
| ) |
|
|
| |
| box = data.get("answerBox") or {} |
| answer = box.get("answer") or box.get("snippet") or None |
| if not answer: |
| kg = data.get("knowledgeGraph") or {} |
| answer = kg.get("description") or None |
|
|
| return JSONResponse({"query": query, "answer": answer, "results": results}) |
|
|
|
|
| @app.post("/api/session") |
| async def session(request: Request): |
| """Proxy the session handshake to the load balancer, keeping its URL secret, |
| and meter conversation time by tier. |
| |
| The browser POSTs here (same-origin); we resolve the caller's tier, refuse if |
| today's budget is already spent (402), otherwise POST <LOAD_BALANCER_URL>/session |
| and relay the JSON back. The LB body carries a per-session `connect_url` |
| (compute host + short-lived token) the browser must dial directly — that one |
| URL is unavoidably exposed, but the stable load-balancer address is not. On a |
| successful grant we reserve the first time chunk against the day's budget.""" |
| if not LOAD_BALANCER_URL: |
| |
| |
| raise HTTPException(status_code=404, detail="Not found.") |
|
|
| tier, keys, set_cookie = auth.resolve_identity(request) |
| |
| |
| tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None |
|
|
| |
| |
| if tracked: |
| rem = await asyncio.to_thread(limiter.remaining, keys, tier) |
| if rem is not None and rem <= 0: |
| resp = JSONResponse( |
| {"tier": tier, "reason": "limit", "remainingSec": 0}, status_code=402 |
| ) |
| if set_cookie: |
| auth.set_anon_cookie(resp, set_cookie) |
| return resp |
|
|
| url = f"{LOAD_BALANCER_URL.rstrip('/')}/session" |
| try: |
| async with httpx.AsyncClient(timeout=15.0) as http: |
| lb = await http.post(url, headers={"Content-Type": "application/json"}, content="{}") |
| except httpx.RequestError as exc: |
| logger.warning("Load balancer unreachable: %r", exc) |
| raise HTTPException(status_code=502, detail="Speech service unreachable.") |
|
|
| |
| |
| if lb.status_code == 503: |
| body = _safe_json(lb) |
| if body.get("state") == "at_capacity": |
| resp = JSONResponse({"state": "at_capacity"}, status_code=503) |
| if set_cookie: |
| auth.set_anon_cookie(resp, set_cookie) |
| return resp |
|
|
| if lb.status_code != 200: |
| |
| |
| logger.warning("Session handshake failed %s: %s", lb.status_code, lb.text[:300]) |
| raise HTTPException(status_code=502, detail=f"Session handshake failed ({lb.status_code}).") |
|
|
| data = lb.json() |
|
|
| |
| |
| if data.get("state") == "queued": |
| data["tier"] = tier |
| resp = JSONResponse(data) |
| if set_cookie: |
| auth.set_anon_cookie(resp, set_cookie) |
| return resp |
|
|
| |
| return await _finalize_grant(data, keys, tier, tracked, set_cookie) |
|
|
|
|
| @app.get("/api/queue/{queue_id}") |
| async def queue_status(queue_id: str, request: Request): |
| """Poll a waiting ticket: relay the position, or — when the head of the line |
| claims a freed slot — reserve the budget now and return the grant. Re-checks the |
| daily budget at claim, since a multi-minute wait could have spent it elsewhere.""" |
| if not LOAD_BALANCER_URL: |
| raise HTTPException(status_code=404, detail="Not found.") |
|
|
| tier, keys, set_cookie = auth.resolve_identity(request) |
| tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None |
|
|
| url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}" |
| try: |
| async with httpx.AsyncClient(timeout=15.0) as http: |
| lb = await http.get(url) |
| except httpx.RequestError as exc: |
| logger.warning("Load balancer unreachable: %r", exc) |
| raise HTTPException(status_code=502, detail="Speech service unreachable.") |
|
|
| if lb.status_code == 404: |
| |
| |
| resp = JSONResponse({"state": "expired"}, status_code=404) |
| if set_cookie: |
| auth.set_anon_cookie(resp, set_cookie) |
| return resp |
|
|
| if lb.status_code != 200: |
| logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300]) |
| raise HTTPException(status_code=502, detail=f"Queue poll failed ({lb.status_code}).") |
|
|
| data = lb.json() |
|
|
| if data.get("state") == "queued": |
| data["tier"] = tier |
| resp = JSONResponse(data) |
| if set_cookie: |
| auth.set_anon_cookie(resp, set_cookie) |
| return resp |
|
|
| |
| |
| |
| if tracked: |
| rem = await asyncio.to_thread(limiter.remaining, keys, tier) |
| if rem is not None and rem <= 0: |
| resp = JSONResponse( |
| {"tier": tier, "reason": "limit", "remainingSec": 0}, status_code=402 |
| ) |
| if set_cookie: |
| auth.set_anon_cookie(resp, set_cookie) |
| return resp |
|
|
| return await _finalize_grant(data, keys, tier, tracked, set_cookie) |
|
|
|
|
| @app.delete("/api/queue/{queue_id}") |
| async def queue_leave(queue_id: str): |
| """Leave the queue from the explicit 'Leave queue' button (a real fetch).""" |
| if not LOAD_BALANCER_URL: |
| raise HTTPException(status_code=404, detail="Not found.") |
| await _lb_leave(queue_id) |
| return {"ok": True} |
|
|
|
|
| @app.post("/api/queue/end") |
| async def queue_end(request: Request): |
| """Leave the queue on teardown/tab-close (navigator.sendBeacon, which can only |
| POST). Body: { queueId }. Best-effort; the LB reaps the ticket on TTL anyway.""" |
| if not LOAD_BALANCER_URL: |
| raise HTTPException(status_code=404, detail="Not found.") |
| qid = await _queue_id(request) |
| if qid: |
| await _lb_leave(qid) |
| return {"ok": True} |
|
|
|
|
| async def _finalize_grant(data, keys, tier, tracked, set_cookie): |
| """Shared grant tail (fast path or queue claim): reserve the first chunk, attach |
| the metering fields the client needs, and set the anon cookie.""" |
| remaining = None |
| if tracked and data.get("session_id"): |
| await asyncio.to_thread(limiter.begin, data["session_id"], keys, tier) |
| remaining = await asyncio.to_thread(limiter.remaining, keys, tier) |
|
|
| data.update({ |
| "tier": tier, |
| "limited": tracked, |
| "remainingSec": remaining, |
| "heartbeatSec": limiter.HEARTBEAT_SEC, |
| }) |
| resp = JSONResponse(data) |
| if set_cookie: |
| auth.set_anon_cookie(resp, set_cookie) |
| return resp |
|
|
|
|
| async def _lb_leave(queue_id: str) -> None: |
| """Best-effort: tell the LB to drop a waiting ticket.""" |
| url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}" |
| try: |
| async with httpx.AsyncClient(timeout=5.0) as http: |
| await http.delete(url) |
| except httpx.RequestError as exc: |
| logger.warning("Queue leave failed: %r", exc) |
|
|
|
|
| def _safe_json(response) -> dict: |
| try: |
| body = response.json() |
| except Exception: |
| return {} |
| return body if isinstance(body, dict) else {} |
|
|
|
|
| async def _queue_id(request: Request) -> str: |
| """Pull `queueId` from a JSON body, tolerating sendBeacon's blob posts.""" |
| try: |
| data = await request.json() |
| except Exception: |
| return "" |
| return (data or {}).get("queueId", "") if isinstance(data, dict) else "" |
|
|
|
|
| async def _session_id(request: Request) -> str: |
| """Pull `sessionId` from a JSON body, tolerating sendBeacon's blob posts.""" |
| try: |
| data = await request.json() |
| except Exception: |
| return "" |
| return (data or {}).get("sessionId", "") if isinstance(data, dict) else "" |
|
|
|
|
| @app.post("/api/session/heartbeat") |
| async def session_heartbeat(request: Request): |
| """Extend the live reservation one chunk at a time. `expired` once the day's |
| budget is spent — the client then tears down.""" |
| if not LIMITER_ENABLED: |
| raise HTTPException(status_code=404, detail="Not found.") |
| sid = await _session_id(request) |
| alive = bool(sid) and await asyncio.to_thread(limiter.heartbeat, sid) |
| return {"expired": not alive} |
|
|
|
|
| @app.post("/api/session/end") |
| async def session_end(request: Request): |
| """Clean teardown: reconcile to real elapsed time and refund the unused |
| chunk. Sent via navigator.sendBeacon, so it must succeed without a response.""" |
| if not LIMITER_ENABLED: |
| raise HTTPException(status_code=404, detail="Not found.") |
| sid = await _session_id(request) |
| if sid: |
| await asyncio.to_thread(limiter.end, sid) |
| return {"ok": True} |
|
|
|
|
| |
| |
| app.mount("/", StaticFiles(directory=HERE, html=True), name="static") |
|
|