refactor(api): retire daily talk-time quotas and account auth

This commit is contained in:
2026-10-06 17:15:32 -03:00
parent 6c4ee3a5e3
commit e4b3833770
6 changed files with 227 additions and 764 deletions
-229
View File
@@ -1,229 +0,0 @@
"""
HF OAuth + per-request identity for the duration limiter.
Login uses Hugging Face's native Spaces OAuth via `huggingface_hub`
(`attach_huggingface_oauth` / `parse_huggingface_oauth`). The OAuth env
(`OAUTH_CLIENT_ID`, ...) is injected by the platform when the Space README sets
`hf_oauth: true`, so this only activates on a deployed Space — locally and in
direct mode there's no OAuth and the limiter treats everyone as anonymous.
Identity:
- signed in -> tier 'pro' | 'free', keyed by hashed HF `sub`
- anonymous -> tier 'anon', keyed by BOTH hashed client IP and a hashed
signed-cookie id (OR-matched in the limiter)
"""
import logging
import os
import secrets
import limiter
logger = logging.getLogger("s2s.auth")
# huggingface_hub adds these routes when OAuth is attached. Centralised so a
# version change is a one-line fix; the paths are handed to the client via
# /api/me rather than hardcoded there.
OAUTH_LOGIN_PATH = "/oauth/huggingface/login"
OAUTH_LOGOUT_PATH = "/oauth/huggingface/logout"
ANON_COOKIE = "s2s_anon"
_COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
# Members of these orgs get unlimited usage (like PRO) out of the box. The
# UNLIMITED_ORGS env adds to this set; it doesn't replace it.
_DEFAULT_UNLIMITED_ORGS = {"cerebras", "huggingfacem4", "smolagents", "pollen-robotics"}
def _unlimited_orgs() -> "set[str]":
"""Org usernames whose members get unlimited usage (like PRO).
Defaults to {cerebras, HuggingFaceM4, smolagents}; the UNLIMITED_ORGS env
(comma/space-separated, e.g. `UNLIMITED_ORGS=my-team`) adds more. Matched
case-insensitively against the signed-in user's organisations."""
raw = os.environ.get("UNLIMITED_ORGS", "")
extra = {o.strip().lower() for o in raw.replace(",", " ").split() if o.strip()}
return _DEFAULT_UNLIMITED_ORGS | extra
try:
from huggingface_hub import attach_huggingface_oauth, parse_huggingface_oauth
_OAUTH_IMPORTABLE = True
except Exception as exc: # pragma: no cover - import guard
logger.info("huggingface_hub OAuth unavailable (%s); sign-in disabled.", exc)
_OAUTH_IMPORTABLE = False
# Set by attach(): True once OAuth is actually wired (importable + env present).
oauth_enabled = False
def attach(app) -> bool:
"""Wire HF OAuth onto the app if it's importable and configured. Returns
whether sign-in is available."""
global oauth_enabled
if not _OAUTH_IMPORTABLE or not os.environ.get("OAUTH_CLIENT_ID"):
return False
try:
attach_huggingface_oauth(app)
oauth_enabled = True
logger.info("HF OAuth attached (sign-in enabled).")
except Exception as exc: # pragma: no cover - defensive
logger.warning("Failed to attach HF OAuth: %r", exc)
oauth_enabled = False
return oauth_enabled
def _field(obj, name, default=None):
"""Read a field whether the user-info is an object or a dict."""
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(name, default)
return getattr(obj, name, default)
# Surface what we detect (orgs, tier) in logs and on /api/me when set. Handy for
# verifying org gating on the live Space without guessing.
AUTH_DEBUG = bool(os.environ.get("AUTH_DEBUG"))
# whoami-v2 org lookups are cached for the process lifetime, keyed by token, so
# /api/me + /api/session don't each hit the Hub.
_orgs_cache: "dict[str, set[str]]" = {}
def current_oauth(request):
"""The parsed HF OAuth info (user_info + access_token), or None."""
if not oauth_enabled:
return None
try:
return parse_huggingface_oauth(request)
except Exception:
return None
def current_user(request):
"""The signed-in HF user-info, or None."""
return _field(current_oauth(request), "user_info")
def _user_org_names(user) -> "set[str]":
"""The user's organisations from the OAuth userinfo, by username/name/id."""
names = set()
for org in _field(user, "orgs", []) or []:
for key in ("preferred_username", "name", "sub"):
val = _field(org, key)
if val:
names.add(str(val).lower())
return names
def _orgs_via_token(token: str) -> "set[str]":
"""Fallback org lookup via the Hub `whoami-v2` API, using the user's OAuth
access token. Covers the case where the userinfo claim omits `orgs`."""
if not token:
return set()
if token in _orgs_cache:
return _orgs_cache[token]
names: "set[str]" = set()
try:
import httpx
resp = httpx.get(
"https://huggingface.co/api/whoami-v2",
headers={"Authorization": f"Bearer {token}"},
timeout=5.0,
)
resp.raise_for_status()
for org in resp.json().get("orgs", []) or []:
for key in ("name", "fullname"):
val = org.get(key)
if val:
names.add(str(val).lower())
except Exception as exc: # pragma: no cover - network/permission dependent
logger.info("whoami-v2 org lookup failed: %r", exc)
_orgs_cache[token] = names
return names
def _org_names(user, token=None, allow=None) -> "set[str]":
"""The user's org usernames from the OAuth userinfo claim. If that doesn't
already satisfy `allow`, fall back to the Hub `whoami-v2` API (the claim is
often empty or partial), so membership is resolved either way."""
names = _user_org_names(user)
if token and (allow is None or not (allow & names)):
names = names | _orgs_via_token(token)
return names
def resolve_tier(user, token=None) -> str:
"""Tier for a signed-in user: 'pro' (paying), 'org' (allow-listed org
member, unlimited), or 'free'. PRO wins over org if both apply."""
if bool(_field(user, "is_pro", False)):
return "pro"
allow = _unlimited_orgs()
names = _org_names(user, token, allow)
tier = "org" if (allow & names) else "free"
if AUTH_DEBUG:
logger.info("tier=%s orgs=%s allow=%s", tier, sorted(names), sorted(allow))
return tier
def user_view(request) -> dict:
"""Public profile for /api/me."""
info = current_oauth(request)
user = _field(info, "user_info")
if not user:
return {"loggedIn": False, "tier": "anon"}
token = _field(info, "access_token")
out = {
"loggedIn": True,
"username": _field(user, "preferred_username") or _field(user, "name") or "tú",
"avatar": _field(user, "picture"),
"tier": resolve_tier(user, token),
}
if AUTH_DEBUG:
out["orgs"] = sorted(_org_names(user, token))
return out
def _client_ip(request) -> str:
"""Real client IP. On HF the app sits behind a proxy, so the user's address
is the first hop in X-Forwarded-For, not request.client.host."""
xff = request.headers.get("x-forwarded-for", "")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def resolve_identity(request):
"""Resolve (tier, keys, set_cookie) for this request.
`keys` are the limiter usage_daily keys to debit (one for signed-in, two for
anonymous). `set_cookie` is a signed value to Set-Cookie when we minted a new
anonymous id, else None.
"""
info = current_oauth(request)
user = _field(info, "user_info")
if user:
sub = _field(user, "sub") or _field(user, "preferred_username")
token = _field(info, "access_token")
return resolve_tier(user, token), [limiter.hash_key(f"sub:{sub}")], None
# Anonymous: key by IP and a signed cookie id, minting the cookie if absent.
ip = _client_ip(request)
cookie_id = limiter.verify_cookie(request.cookies.get(ANON_COOKIE, ""))
set_cookie = None
if not cookie_id:
cookie_id = secrets.token_urlsafe(18)
set_cookie = limiter.sign_cookie(cookie_id)
keys = [limiter.hash_key(f"ip:{ip}"), limiter.hash_key(f"cookie:{cookie_id}")]
return "anon", keys, set_cookie
def set_anon_cookie(response, signed: str) -> None:
# The Space runs inside an iframe on huggingface.co, so the cookie lives in a
# cross-site context — it must be SameSite=None; Secure or the browser drops it.
response.set_cookie(
ANON_COOKIE, signed,
max_age=_COOKIE_MAX_AGE, httponly=True, samesite="none", secure=True,
)
-276
View File
@@ -1,276 +0,0 @@
"""
Per-day talk-time budget for the speech-to-speech demo.
Our server isn't in the audio path (the browser dials the compute WebSocket
directly), so it can't cut a live stream. What it *can* do is meter time with a
server-clock, chunked reservation:
- At grant we reserve the first chunk (CHUNK_SEC) and debit it from the day's
budget. A parallel grant therefore sees the budget already spent.
- The client heartbeats; each heartbeat extends the reservation one chunk at a
time until the daily budget runs out, then we report `expired` so the client
tears down.
- On a clean end (sendBeacon) we reconcile to the real elapsed time and refund
the unused chunk. A crash (no end, no heartbeats) is reaped by a sweep and
forfeits at most one chunk.
All time is the server's clock. Budgets are per UTC day; a new day is simply a
new row (no explicit reset). Logged-in users are keyed by a hashed HF `sub`;
anonymous users by BOTH a hashed IP and a hashed signed-cookie id, OR-matched
(spent = max of the two) so clearing one identifier doesn't reset the budget.
Storage is SQLite at $USAGE_DB_PATH, else /data (persistent Spaces storage),
else a /tmp fallback. On /tmp the budget is only per-uptime — flagged in logs.
"""
import hashlib
import hmac
import logging
import math
import os
import sqlite3
import tempfile
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger("s2s.limiter")
# ── Tunables (env-overridable) ───────────────────────────────────────────────
ANON_SEC = int(os.environ.get("LIMIT_ANON_SEC", "300")) # 5 min/day, not signed in
FREE_SEC = int(os.environ.get("LIMIT_FREE_SEC", "600")) # 10 min/day, signed in, no PRO
CHUNK_SEC = int(os.environ.get("RESERVE_CHUNK_SEC", "10")) # reservation granularity
HEARTBEAT_SEC = int(os.environ.get("HEARTBEAT_SEC", "5")) # advertised client cadence
REAP_AFTER_SEC = int(os.environ.get("SESSION_REAP_SEC", "15")) # silence before sweep
# Stable across restarts or the hashed keys (and signed cookies) rotate and the
# budget effectively resets. Set it as a Space secret. Falls back to a per-boot
# random value (keys then only hold within one uptime).
_HASH_SECRET = (os.environ.get("USAGE_HASH_SECRET", "").strip() or os.urandom(32).hex()).encode()
_lock = threading.Lock()
_db_path: "Path | None" = None
def budget_for(tier: str) -> "int | None":
"""Daily second-budget for a tier, or None for unlimited.
Unlimited tiers: 'pro' (paying PRO members) and 'org' (members of an
allow-listed organisation, see UNLIMITED_ORGS in auth.py)."""
if tier in ("pro", "org"):
return None
if tier == "free":
return FREE_SEC
return ANON_SEC
def hash_key(raw: str) -> str:
"""HMAC a raw identifier (sub / ip / cookie id) into an opaque storage key."""
digest = hmac.new(_HASH_SECRET, raw.encode("utf-8"), hashlib.sha256).hexdigest()
return f"k_{digest}"
def sign_cookie(value: str) -> str:
"""`<id>.<sig>` so a forged anon-cookie id is rejected on read."""
sig = hmac.new(_HASH_SECRET, value.encode("utf-8"), hashlib.sha256).hexdigest()[:32]
return f"{value}.{sig}"
def verify_cookie(signed: str) -> "str | None":
"""Return the id if the signature checks out, else None."""
if not signed or "." not in signed:
return None
value, _, sig = signed.rpartition(".")
want = hmac.new(_HASH_SECRET, value.encode("utf-8"), hashlib.sha256).hexdigest()[:32]
return value if hmac.compare_digest(sig, want) else None
def _today() -> str:
return datetime.now(timezone.utc).date().isoformat()
def _resolve_db_path() -> Path:
explicit = os.environ.get("USAGE_DB_PATH", "").strip()
if explicit:
return Path(explicit)
data = Path("/data")
if data.is_dir() and os.access(data, os.W_OK):
return data / "s2s-usage.sqlite3"
logger.warning("No persistent /data — usage budget falls back to /tmp (per-uptime only).")
return Path(tempfile.gettempdir()) / "s2s-usage.sqlite3"
def _connect() -> sqlite3.Connection:
con = sqlite3.connect(_db_path, timeout=5.0)
con.execute("PRAGMA journal_mode=WAL")
con.execute("PRAGMA busy_timeout=5000")
return con
def init() -> None:
"""Create the schema. Call once at startup."""
global _db_path
_db_path = _resolve_db_path()
with _lock, _connect() as con:
con.execute(
"""CREATE TABLE IF NOT EXISTS usage_daily (
user_key TEXT NOT NULL,
day TEXT NOT NULL,
spent_sec INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_key, day)
)"""
)
con.execute(
"""CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
keys TEXT NOT NULL, -- comma-joined usage_daily keys to debit
day TEXT NOT NULL,
tier TEXT NOT NULL,
grant_ts REAL NOT NULL,
last_seen_ts REAL NOT NULL,
reserved_sec INTEGER NOT NULL,
ended INTEGER NOT NULL DEFAULT 0
)"""
)
logger.info("Usage limiter ready at %s (anon=%ss free=%ss chunk=%ss)", _db_path, ANON_SEC, FREE_SEC, CHUNK_SEC)
# ── Internal helpers (call under _lock) ───────────────────────────────────────
def _spent(con, key: str, day: str) -> int:
row = con.execute(
"SELECT spent_sec FROM usage_daily WHERE user_key=? AND day=?", (key, day)
).fetchone()
return int(row[0]) if row else 0
def _spent_max(con, keys, day: str) -> int:
"""OR-match: the most-spent identifier governs."""
return max((_spent(con, k, day) for k in keys), default=0)
def _add(con, keys, day: str, delta: int) -> None:
now = int(time.time())
for k in keys:
cur = _spent(con, k, day)
nxt = max(0, cur + delta)
con.execute(
"""INSERT INTO usage_daily (user_key, day, spent_sec, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_key, day) DO UPDATE SET
spent_sec = excluded.spent_sec, updated_at = excluded.updated_at""",
(k, day, nxt, now),
)
# ── Public API ────────────────────────────────────────────────────────────────
def remaining(keys, tier: str) -> "int | None":
"""Seconds left today for these keys (None = unlimited). No mutation."""
budget = budget_for(tier)
if budget is None:
return None
with _lock, _connect() as con:
return max(0, budget - _spent_max(con, keys, _today()))
def begin(session_id: str, keys, tier: str) -> int:
"""Reserve the first chunk for a new session and record it. Returns the
chunk reserved (0 if the budget is already exhausted — the first heartbeat
will then expire it). PRO (unlimited) is never tracked; don't call it here."""
day = _today()
budget = budget_for(tier)
now = time.time()
with _lock, _connect() as con:
avail = budget - _spent_max(con, keys, day) if budget is not None else CHUNK_SEC
chunk = max(0, min(CHUNK_SEC, avail))
if chunk:
_add(con, keys, day, chunk)
con.execute(
"""INSERT OR REPLACE INTO sessions
(session_id, keys, day, tier, grant_ts, last_seen_ts, reserved_sec, ended)
VALUES (?, ?, ?, ?, ?, ?, ?, 0)""",
(session_id, ",".join(keys), day, tier, now, now, chunk),
)
return chunk
def heartbeat(session_id: str) -> bool:
"""Keep a session alive: extend the reservation toward `elapsed + 1 chunk`,
debiting the budget chunk by chunk. Returns True while alive, False once the
budget is spent (caller should tear down) or the session is unknown/ended."""
now = time.time()
with _lock, _connect() as con:
row = con.execute(
"SELECT keys, day, tier, grant_ts, reserved_sec, ended FROM sessions WHERE session_id=?",
(session_id,),
).fetchone()
if not row or row[5]:
return False
keys = row[0].split(",")
day, tier, grant_ts, reserved = row[1], row[2], row[3], int(row[4])
budget = budget_for(tier)
elapsed = now - grant_ts
# Grow the reservation one chunk at a time until it covers elapsed + a
# one-chunk lookahead, or the budget runs dry.
while reserved < elapsed + CHUNK_SEC:
if budget is not None and _spent_max(con, keys, day) >= budget:
break
_add(con, keys, day, CHUNK_SEC)
reserved += CHUNK_SEC
alive = reserved > elapsed # could we cover the time already elapsed?
con.execute(
"UPDATE sessions SET last_seen_ts=?, reserved_sec=?, ended=? WHERE session_id=?",
(now, reserved, 0 if alive else 1, session_id),
)
if not alive:
_reconcile(con, keys, day, grant_ts, reserved, end_ts=now)
return alive
def end(session_id: str) -> None:
"""Clean teardown: reconcile to actual elapsed time and refund the unused
reservation. Idempotent."""
now = time.time()
with _lock, _connect() as con:
row = con.execute(
"SELECT keys, day, grant_ts, reserved_sec, ended FROM sessions WHERE session_id=?",
(session_id,),
).fetchone()
if not row or row[4]:
return
keys, day, grant_ts, reserved = row[0].split(","), row[1], row[2], int(row[3])
_reconcile(con, keys, day, grant_ts, reserved, end_ts=now)
con.execute("UPDATE sessions SET ended=1, last_seen_ts=? WHERE session_id=?", (now, session_id))
def sweep() -> None:
"""Reap sessions that went silent (crash / closed without a beacon): bill
their elapsed time, refund the rest, mark ended. Forfeits ≤ one chunk."""
now = time.time()
cutoff = now - REAP_AFTER_SEC
with _lock, _connect() as con:
stale = con.execute(
"SELECT session_id, keys, day, grant_ts, last_seen_ts, reserved_sec FROM sessions "
"WHERE ended=0 AND last_seen_ts < ?",
(cutoff,),
).fetchall()
for session_id, keys_s, day, grant_ts, last_seen, reserved in stale:
_reconcile(con, keys_s.split(","), day, grant_ts, int(reserved), end_ts=last_seen)
con.execute("UPDATE sessions SET ended=1 WHERE session_id=?", (session_id,))
if stale:
logger.debug("swept %d stale session(s)", len(stale))
def _reconcile(con, keys, day: str, grant_ts: float, reserved: int, end_ts: float) -> None:
"""Refund reserved-but-unused time. Bill elapsed rounded up to a chunk,
capped at what was reserved."""
elapsed = max(0.0, end_ts - grant_ts)
billed = min(reserved, int(math.ceil(elapsed / CHUNK_SEC) * CHUNK_SEC))
refund = reserved - billed
if refund > 0:
_add(con, keys, day, -refund)
-2
View File
@@ -2,5 +2,3 @@ fastapi==0.115.6
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
httpx==0.28.1 httpx==0.28.1
python-dotenv==1.2.2 python-dotenv==1.2.2
# [oauth] extra pulls authlib + itsdangerous for attach_huggingface_oauth.
huggingface_hub[oauth]>=0.30,<1.0
+27 -256
View File
@@ -1,43 +1,24 @@
""" """
Tiny server for the speech-to-speech demo. API and WebSocket proxy for the speech-to-speech frontend.
The demo used to ship as a `sdk: static` Space, but the web-search tool needs a Vite serves the React application and forwards backend requests here. Search,
search key the browser must NOT see. A static Space has no runtime process, so it caller-profile, and tool credentials stay server-side. `/api/session` proxies
can't hold a secret the front-end uses. This server fixes that: it serves the load-balancer allocation so the browser receives only the per-session compute
unchanged front-end AND exposes a same-origin `/api/search` proxy that holds the URL it must dial. A pinned direct URL or WebSocket proxy target disables the LB.
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).
`SPEECH_TO_SPEECH_URL` overrides everything: when set, the LB logic above is
disabled entirely (no session proxy, no queue, no metering, no sign-in) and the
browser connects directly to that URL, shown read-only in Settings.
Endpoints: Endpoints:
GET /api/config -> { search, lb, allowDirect, s2sUrl, auth } GET /api/config -> frontend settings and available integrations
GET /api/me -> login + tier + remaining budget (LB mode only)
POST /api/search -> { results, answer } Google via Serper.dev POST /api/search -> { results, answer } Google via Serper.dev
POST /api/session -> proxies <LB>/session: a grant, or a queue ticket 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 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) DELETE /api/queue/{id} -> leave the queue (explicit "Leave queue" button)
POST /api/queue/end -> leave the queue (sendBeacon on teardown) POST /api/queue/end -> leave the queue (sendBeacon on teardown)
POST /api/session/heartbeat-> extend the reservation; { expired } WS /v1/realtime -> optional speech-service WebSocket proxy
POST /api/session/end -> reconcile + refund (sendBeacon on teardown) GET /docs/* -> shared project documentation
/* -> static files (index.html, main.js, ...)
When every compute slot is busy the load balancer hands back a queue ticket 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 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 and a slot frees. Queue deadlines and pending-grant timeouts belong to the LB.
the moment a slot is actually claimed (a grant), never while queued.
""" """
from __future__ import annotations from __future__ import annotations
@@ -58,9 +39,6 @@ from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
import auth
import limiter
logger = logging.getLogger("s2s") logger = logging.getLogger("s2s")
_logging_configured = False _logging_configured = False
if not _logging_configured: if not _logging_configured:
@@ -1402,9 +1380,9 @@ NODE_RED_REASONING_URL = os.environ.get("S2S_REASONING_NODE_RED_URL", "").strip(
# browser connects to it straight (no load balancer). # browser connects to it straight (no load balancer).
LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip() LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip()
# Direct s2s server URL pinned by the deploy. Takes priority over the load # Direct s2s server URL pinned by the deploy. Takes priority over the load
# balancer: when set, ALL LB logic is disabled (no /api/session proxy, no queue, # balancer: when set, LB allocation and queuing are disabled, and the browser
# no limiter, no sign-in) and the browser connects to this URL directly. Unlike # connects to this URL directly. Unlike the LB address it is NOT a secret —
# the LB address it is NOT a secret — /api/config sends it to the client, which # /api/config sends it to the client, which
# shows it read-only in Settings. # shows it read-only in Settings.
SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip() SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip()
# Optional same-origin WebSocket relay target. When set, the browser dials this # Optional same-origin WebSocket relay target. When set, the browser dials this
@@ -1413,13 +1391,6 @@ SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip()
S2S_PROXY_TARGET = os.environ.get("S2S_PROXY_TARGET", "").strip() S2S_PROXY_TARGET = os.environ.get("S2S_PROXY_TARGET", "").strip()
if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET: if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET:
LOAD_BALANCER_URL = "" LOAD_BALANCER_URL = ""
# HF injects SPACE_ID ("owner/space") into every Space runtime; it's absent
# locally and on a plain `docker run`. We meter conversation time ONLY on the
# deployed Space — i.e. when BOTH the LB is configured AND we're on a Space.
# Off-Space (local dev, even with the LB exported) the app still proxies the LB,
# but nothing is metered: no budget, no reservations, no sign-in gating.
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" SERPER_URL = "https://google.serper.dev/search"
# Cap results so the tool output stays small enough to feed back to the model. # Cap results so the tool output stays small enough to feed back to the model.
MAX_RESULTS = 5 MAX_RESULTS = 5
@@ -1427,10 +1398,6 @@ HERE = os.path.dirname(os.path.abspath(__file__))
app = FastAPI(title="s2s-demo") app = FastAPI(title="s2s-demo")
# Wire HF OAuth before the app serves (no-op unless the OAuth env is present).
# Sign-in only matters when we're metering (prod Space), so gate it on that.
AUTH_ENABLED = LIMITER_ENABLED and auth.attach(app)
# Resolve the execution URL for the startup banner using the same normalization # Resolve the execution URL for the startup banner using the same normalization
# as _apipilot_resolve_execute_url. # as _apipilot_resolve_execute_url.
def _resolve_exec_url_for_startup_banner(): def _resolve_exec_url_for_startup_banner():
@@ -1444,7 +1411,7 @@ _STARTUP_APIPILOT_EXEC_URL = _resolve_exec_url_for_startup_banner()
del _resolve_exec_url_for_startup_banner del _resolve_exec_url_for_startup_banner
logger.info( logger.info(
"[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s auth=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s", "[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s",
S2S_REASONING_TYPE, S2S_REASONING_TYPE,
NODE_RED_REASONING_URL or "-", NODE_RED_REASONING_URL or "-",
bool(SERPER_KEY), bool(SERPER_KEY),
@@ -1464,7 +1431,6 @@ logger.info(
S2S_DEBUG_PROMPT, S2S_DEBUG_PROMPT,
S2S_START_SESSION_MODE or "-", S2S_START_SESSION_MODE or "-",
S2S_THEME or "-", S2S_THEME or "-",
AUTH_ENABLED,
S2S_APIPILOT_TOOLS_URL or "-", S2S_APIPILOT_TOOLS_URL or "-",
(S2S_APIPILOT_TOOLS_EXECUTE_URL + f"(resolved:{_STARTUP_APIPILOT_EXEC_URL})") if S2S_APIPILOT_TOOLS_EXECUTE_URL else "-", (S2S_APIPILOT_TOOLS_EXECUTE_URL + f"(resolved:{_STARTUP_APIPILOT_EXEC_URL})") if S2S_APIPILOT_TOOLS_EXECUTE_URL else "-",
"Y" if bool(S2S_APIPILOT_SERVICE_TOKEN) else "-", "Y" if bool(S2S_APIPILOT_SERVICE_TOKEN) else "-",
@@ -1474,10 +1440,6 @@ logger.info(
("YES head=" + (S2S_APIPILOT_API_KEY[:6] + "..." if len(S2S_APIPILOT_API_KEY) > 6 else S2S_APIPILOT_API_KEY) + " (3 FORMS!)") if S2S_APIPILOT_API_KEY else "-", ("YES head=" + (S2S_APIPILOT_API_KEY[:6] + "..." if len(S2S_APIPILOT_API_KEY) > 6 else S2S_APIPILOT_API_KEY) + " (3 FORMS!)") if S2S_APIPILOT_API_KEY else "-",
) )
del _STARTUP_APIPILOT_EXEC_URL del _STARTUP_APIPILOT_EXEC_URL
logger.debug(
"[STARTUP] Runtime config — SPACE_ID=%s LIMITER_ENABLED=%s MAX_RESULTS=%d",
bool(SPACE_ID), LIMITER_ENABLED, MAX_RESULTS,
)
async def _report_server_debug(hypothesis_id: str, location: str, msg: str, data: dict | None = None) -> None: async def _report_server_debug(hypothesis_id: str, location: str, msg: str, data: dict | None = None) -> None:
@@ -1652,22 +1614,6 @@ _NOISY_CLIENT_EVENT_TYPES = frozenset({
"input_audio_buffer.speech_started", "input_audio_buffer.speech_started",
"input_audio_buffer.speech_stopped", "input_audio_buffer.speech_stopped",
}) })
@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: # pragma: no cover - defensive
logger.warning("usage sweep failed: %r", exc)
class SearchRequest(BaseModel): class SearchRequest(BaseModel):
@@ -3334,8 +3280,8 @@ def _effective_s2s_url(request: Request) -> str:
@app.get("/api/config") @app.get("/api/config")
async def config(request: Request): async def config(request: Request):
"""Client bootstrap: whether web search is available, whether the deploy runs """Client bootstrap: whether web search is available, whether the deploy runs
behind a load balancer (so the browser uses the /api/session proxy + limiter), behind a load balancer (so the browser uses the /api/session proxy),
whether HF sign-in is available, and whether the user may instead set a direct and whether the user may instead set a direct
s2s server URL. The LB address itself is intentionally NOT included. s2s server URL. The LB address itself is intentionally NOT included.
2026-09-24: new top-level `apipilot` flag. When True the deploy exposes the 2026-09-24: new top-level `apipilot` flag. When True the deploy exposes the
@@ -3351,7 +3297,6 @@ async def config(request: Request):
"s2sUrl": _effective_s2s_url(request), "s2sUrl": _effective_s2s_url(request),
"primaryPrompt": primary_prompt, "primaryPrompt": primary_prompt,
"reasoningType": S2S_REASONING_TYPE, "reasoningType": S2S_REASONING_TYPE,
"auth": AUTH_ENABLED,
"injectProfile": S2S_INJECT_PROFILE, "injectProfile": S2S_INJECT_PROFILE,
"debugPrompt": S2S_DEBUG_PROMPT, "debugPrompt": S2S_DEBUG_PROMPT,
"startSessionMode": S2S_START_SESSION_MODE, "startSessionMode": S2S_START_SESSION_MODE,
@@ -5029,40 +4974,6 @@ async def user_reports(phoneNumber: str | None = None):
return JSONResponse(resp_body) return JSONResponse(resp_body)
@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."""
client_ip = request.client.host if request.client else "?"
logger.debug(
"[INTERNAL] /api/me client=%s LIMITER=%s",
client_ip, LIMITER_ENABLED,
)
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)
logger.debug(
"[INTERNAL] /api/me client=%s tier=%s unlimited=%s remaining=%s setCookie=%s",
client_ip, tier, unlimited, rem, bool(set_cookie),
)
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") @app.post("/api/search")
async def search(req: SearchRequest): async def search(req: SearchRequest):
"""Proxy a Google search via Serper.dev. The key stays on the server unless """Proxy a Google search via Serper.dev. The key stays on the server unless
@@ -5136,44 +5047,17 @@ async def search(req: SearchRequest):
@app.post("/api/session") @app.post("/api/session")
async def session(request: Request): async def session(request: Request):
"""Proxy the session handshake to the load balancer, keeping its URL secret, """Relay a session grant or queue ticket, keeping the LB address server-side.
and meter conversation time by tier.
The browser POSTs here (same-origin); we resolve the caller's tier, refuse if The grant's `connect_url` contains the compute host and short-lived token the
today's budget is already spent (402), otherwise POST <LOAD_BALANCER_URL>/session browser needs to connect directly to the allocated speech service."""
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."""
client_ip = request.client.host if request.client else "?" client_ip = request.client.host if request.client else "?"
if not LOAD_BALANCER_URL: if not LOAD_BALANCER_URL:
# No LB configured — this deploy is direct-mode only; the browser should # No LB configured — this deploy is direct-mode only; the browser should
# never call this. 404 so it's indistinguishable from a missing route. # never call this. 404 so it's indistinguishable from a missing route.
raise HTTPException(status_code=404, detail="No encontrado.") raise HTTPException(status_code=404, detail="No encontrado.")
tier, keys, set_cookie = auth.resolve_identity(request) logger.debug("[EXT][LB] session: client=%s", client_ip)
# Metering runs only on the deployed Space; off-Space the LB still proxies but
# nothing is tracked. Within metering, unlimited tiers (pro, org) aren't either.
tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None
logger.debug(
"[EXT][LB] session: client=%s tier=%s tracked=%s LIMITER=%s",
client_ip, tier, tracked, LIMITER_ENABLED,
)
# Refuse before troubling the LB if the day's budget is already gone. Done
# here (at enqueue) so we never put a user who can't talk into the queue.
if tracked:
rem = await asyncio.to_thread(limiter.remaining, keys, tier)
logger.debug("[EXT][LB] session: client=%s budget remaining=%s", client_ip, rem)
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" url = f"{LOAD_BALANCER_URL.rstrip('/')}/session"
logger.debug("[EXT][LB] session POST %s (handshake)", url) logger.debug("[EXT][LB] session POST %s (handshake)", url)
try: try:
@@ -5193,10 +5077,7 @@ async def session(request: Request):
body = _safe_json(lb) body = _safe_json(lb)
if body.get("state") == "at_capacity": if body.get("state") == "at_capacity":
logger.debug("[EXT][LB] session: at_capacity — queue full") logger.debug("[EXT][LB] session: at_capacity — queue full")
resp = JSONResponse({"state": "at_capacity"}, status_code=503) return JSONResponse({"state": "at_capacity"}, status_code=503)
if set_cookie:
auth.set_anon_cookie(resp, set_cookie)
return resp
if lb.status_code != 200: if lb.status_code != 200:
# The LB's error body may name the reason (e.g. capacity); it carries no # The LB's error body may name the reason (e.g. capacity); it carries no
@@ -5213,36 +5094,24 @@ async def session(request: Request):
state, session_id, bool(connect_url), connect_url, state, session_id, bool(connect_url), connect_url,
) )
# Busy pool: the LB queued us. Relay the ticket untouched — crucially with NO # Busy pool: the LB queued us. Relay the ticket for subsequent polling.
# reservation, so waiting in line never costs the day's budget.
if data.get("state") == "queued": if data.get("state") == "queued":
data["tier"] = tier
logger.debug("[EXT][LB] session: queued queueId=%s position=%s", logger.debug("[EXT][LB] session: queued queueId=%s position=%s",
data.get("queue_id") or data.get("queueId"), data.get("queue_id") or data.get("queueId"),
data.get("position")) data.get("position"))
resp = JSONResponse(data) return JSONResponse(data)
if set_cookie:
auth.set_anon_cookie(resp, set_cookie)
return resp
# A slot was free: reserve the first chunk now and return the grant.
return await _finalize_grant(data, keys, tier, tracked, set_cookie)
@app.get("/api/queue/{queue_id}") @app.get("/api/queue/{queue_id}")
async def queue_status(queue_id: str, request: Request): async def queue_status(queue_id: str, request: Request):
"""Poll a waiting ticket: relay the position, or — when the head of the line """Relay a waiting ticket's position or its newly claimed session grant."""
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."""
client_ip = request.client.host if request.client else "?" client_ip = request.client.host if request.client else "?"
if not LOAD_BALANCER_URL: if not LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="No encontrado.") raise HTTPException(status_code=404, detail="No encontrado.")
tier, keys, set_cookie = auth.resolve_identity(request)
tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None
logger.debug( logger.debug(
"[EXT][LB] queue poll: client=%s queueId=%s tier=%s", "[EXT][LB] queue poll: client=%s queueId=%s",
client_ip, queue_id[:16], tier, client_ip, queue_id[:16],
) )
url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}" url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
@@ -5262,10 +5131,7 @@ async def queue_status(queue_id: str, request: Request):
# Ticket unknown/expired (reaped after we stopped polling). Tell the client # Ticket unknown/expired (reaped after we stopped polling). Tell the client
# to start over rather than spin. # to start over rather than spin.
logger.debug("[EXT][LB] queue: queueId=%s EXPIRED (404)", queue_id[:16]) logger.debug("[EXT][LB] queue: queueId=%s EXPIRED (404)", queue_id[:16])
resp = JSONResponse({"state": "expired"}, status_code=404) return JSONResponse({"state": "expired"}, status_code=404)
if set_cookie:
auth.set_anon_cookie(resp, set_cookie)
return resp
if lb.status_code != 200: if lb.status_code != 200:
logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300]) logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300])
@@ -5280,31 +5146,7 @@ async def queue_status(queue_id: str, request: Request):
data.get("session_id") or data.get("sessionId"), data.get("session_id") or data.get("sessionId"),
) )
if data.get("state") == "queued": return JSONResponse(data)
data["tier"] = tier
resp = JSONResponse(data)
if set_cookie:
auth.set_anon_cookie(resp, set_cookie)
return resp
# Claimed a slot. Re-check the budget: it may have been spent in another tab
# during the wait. If so, refuse — the just-claimed slot is now a pending
# session on the LB and its pending-timeout reaper reclaims it shortly.
if tracked:
rem = await asyncio.to_thread(limiter.remaining, keys, tier)
logger.debug(
"[EXT][LB] queue CLAIMED queueId=%s budget remaining=%s",
queue_id[:16], rem,
)
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}") @app.delete("/api/queue/{queue_id}")
@@ -5330,35 +5172,6 @@ async def queue_end(request: Request):
return {"ok": True} 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
session_id = data.get("session_id")
if tracked and session_id:
logger.debug(
"[EXT][LB] grant FINALIZE sessionId=%s tier=%s tracked=%s",
session_id, tier, tracked,
)
await asyncio.to_thread(limiter.begin, session_id, keys, tier)
remaining = await asyncio.to_thread(limiter.remaining, keys, tier)
logger.debug(
"[EXT][LB] grant RESERVED sessionId=%s remaining=%s",
session_id, remaining,
)
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: async def _lb_leave(queue_id: str) -> None:
"""Best-effort: tell the LB to drop a waiting ticket.""" """Best-effort: tell the LB to drop a waiting ticket."""
url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}" url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
@@ -6480,48 +6293,6 @@ async def _queue_id(request: Request) -> str:
return (data or {}).get("queueId", "") if isinstance(data, dict) else "" 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."""
client_ip = request.client.host if request.client else "?"
if not LIMITER_ENABLED:
raise HTTPException(status_code=404, detail="No encontrado.")
sid = await _session_id(request)
alive = bool(sid) and await asyncio.to_thread(limiter.heartbeat, sid)
logger.debug(
"[INTERNAL] /api/session/heartbeat client=%s sessionId=%s alive=%s",
client_ip, (sid or "")[:16], alive,
)
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."""
client_ip = request.client.host if request.client else "?"
if not LIMITER_ENABLED:
raise HTTPException(status_code=404, detail="No encontrado.")
sid = await _session_id(request)
logger.debug(
"[INTERNAL] /api/session/end client=%s sessionId=%s",
client_ip, (sid or "")[:16],
)
if sid:
await asyncio.to_thread(limiter.end, sid)
return {"ok": True}
@app.middleware("http") @app.middleware("http")
async def no_cache_static_assets(request: Request, call_next): async def no_cache_static_assets(request: Request, call_next):
response = await call_next(request) response = await call_next(request)
+199
View File
@@ -0,0 +1,199 @@
"""Backend contract checks with local HTTP fixtures and a loopback voice server.
Run from the repository root: .venv/bin/python -m unittest discover -s s2s-client/tests
"""
import json
import os
from pathlib import Path
import sys
import threading
import unittest
from unittest.mock import Mock, patch
import httpx
from fastapi.testclient import TestClient
from websockets.sync.server import serve
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# Never consume deployment credentials or contact services from a developer's .env.
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
import server
GRANT = {
"session_id": "local-session",
"connect_url": "ws://voice.test/v1/realtime?token=fixture",
"pending_timeout_s": 60,
}
TICKET = {"state": "queued", "queue_id": "local-ticket", "position": 2, "poll_interval_s": 1}
ASYNC_CLIENT = httpx.AsyncClient
class BackendContractTests(unittest.TestCase):
def setUp(self):
self.upstream = Mock(side_effect=AssertionError("Unexpected upstream HTTP request"))
self.enterContext(patch.object(server, "LOAD_BALANCER_URL", "http://lb.test"))
self.enterContext(patch.object(
server.httpx, "AsyncClient",
side_effect=lambda **kwargs: ASYNC_CLIENT(
transport=httpx.MockTransport(self.upstream), **kwargs,
),
))
self.client = self.enterContext(TestClient(server.app))
def respond(self, *responses):
self.upstream.side_effect = iter(responses)
def test_bootstrap_and_removed_account_contract(self):
response = self.client.get("/api/config")
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["lb"])
self.assertFalse(response.json()["allowDirect"])
self.assertNotIn("auth", response.json())
paths = self.client.get("/openapi.json").json()["paths"]
for path in ("/api/me", "/api/session/heartbeat", "/api/session/end"):
self.assertNotIn(path, paths)
self.assertFalse(any(getattr(route, "path", "").startswith("/oauth") for route in server.app.routes))
self.upstream.assert_not_called()
def test_session_grant_passes_through_without_extra_fields_or_cookies(self):
self.respond(httpx.Response(200, json=GRANT))
response = self.client.post("/api/session", json={})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), GRANT)
self.assertNotIn("set-cookie", response.headers)
request = self.upstream.call_args.args[0]
self.assertEqual((request.method, str(request.url)), ("POST", "http://lb.test/session"))
self.assertEqual(json.loads(request.content), {})
def test_queue_polling_returns_positions_and_then_grant(self):
self.respond(
httpx.Response(200, json=TICKET),
httpx.Response(200, json={**TICKET, "position": 1}),
httpx.Response(200, json=GRANT),
)
self.assertEqual(self.client.post("/api/session").json(), TICKET)
self.assertEqual(self.client.get("/api/queue/local-ticket").json(), {**TICKET, "position": 1})
response = self.client.get("/api/queue/local-ticket")
self.assertEqual(response.json(), GRANT)
self.assertNotIn("set-cookie", response.headers)
self.assertEqual(
[(call.args[0].method, call.args[0].url.path) for call in self.upstream.call_args_list],
[("POST", "/session"), ("GET", "/queue/local-ticket"), ("GET", "/queue/local-ticket")],
)
def test_capacity_and_expired_queue_responses_remain(self):
self.respond(httpx.Response(503, json={"state": "at_capacity"}), httpx.Response(404))
response = self.client.post("/api/session")
self.assertEqual((response.status_code, response.json()), (503, {"state": "at_capacity"}))
response = self.client.get("/api/queue/local-ticket")
self.assertEqual((response.status_code, response.json()), (404, {"state": "expired"}))
def test_explicit_and_beacon_queue_cancellation(self):
self.respond(httpx.Response(204), httpx.Response(204))
self.assertEqual(self.client.delete("/api/queue/local-ticket").json(), {"ok": True})
# sendBeacon posts a JSON blob, not a form or a session identifier.
response = self.client.post(
"/api/queue/end", content=json.dumps({"queueId": "local-ticket"}),
headers={"Content-Type": "application/json"},
)
self.assertEqual(response.json(), {"ok": True})
for call in self.upstream.call_args_list:
self.assertEqual((call.args[0].method, call.args[0].url.path), ("DELETE", "/queue/local-ticket"))
self.assertEqual(self.upstream.call_count, 2)
def test_empty_beacon_is_harmless_and_cancellation_is_best_effort(self):
self.assertEqual(self.client.post("/api/queue/end", content="").json(), {"ok": True})
self.upstream.assert_not_called()
self.upstream.side_effect = httpx.ConnectError("Local fixture unavailable")
self.assertEqual(self.client.delete("/api/queue/local-ticket").json(), {"ok": True})
def test_upstream_failures_are_still_reported(self):
self.upstream.side_effect = httpx.ConnectError("Local fixture unavailable")
self.assertEqual(self.client.post("/api/session").status_code, 502)
self.assertEqual(self.client.get("/api/queue/local-ticket").status_code, 502)
self.respond(httpx.Response(429), httpx.Response(500))
self.assertEqual(self.client.post("/api/session").status_code, 502)
self.assertEqual(self.client.get("/api/queue/local-ticket").status_code, 502)
def test_direct_mode_disables_lb_and_preserves_pinned_voice_url(self):
with patch.object(server, "LOAD_BALANCER_URL", ""), patch.object(
server, "SPEECH_TO_SPEECH_URL", GRANT["connect_url"],
):
config = self.client.get("/api/config").json()
self.assertEqual(config["s2sUrl"], GRANT["connect_url"])
self.assertFalse(config["lb"])
self.assertTrue(config["allowDirect"])
for method, path in (
("POST", "/api/session"), ("GET", "/api/queue/local-ticket"),
("DELETE", "/api/queue/local-ticket"), ("POST", "/api/queue/end"),
):
self.assertEqual(self.client.request(method, path).status_code, 404)
self.upstream.assert_not_called()
def test_caller_profile_still_uses_upstream_login_token(self):
profile = {"result": {"name": "Local Caller"}}
self.respond(
httpx.Response(200, json={"token": "fixture-token"}),
httpx.Response(200, json=profile),
)
with patch.object(server, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"), patch.object(
server, "VOCERO_USER_PHONE_AUTH_URL", "http://profile.test/user",
):
response = self.client.get("/api/user/phone", params={"phoneNumber": "+56000000000"})
self.assertEqual(response.json(), profile)
login, lookup = [call.args[0] for call in self.upstream.call_args_list]
self.assertEqual(json.loads(login.content), {"phoneNumber": "+56000000000"})
self.assertEqual(lookup.headers["authorization"], "Bearer fixture-token")
self.assertEqual(lookup.url.params["phoneNumber"], "+56000000000")
def test_caller_auth_headers_remain_independent(self):
request = server.Request({
"type": "http", "headers": [
(b"authorization", b"Bearer fixture-token"), (b"x-demo-session", b"fixture-demo"),
],
})
self.assertEqual(server._apipilot_parse_authorization(request), ("Bearer fixture-token", "fixture-demo"))
class VoiceProxyTests(unittest.TestCase):
def test_proxy_voice_frames_keepalive_and_client_disconnect(self):
closed = threading.Event()
received = []
keepalive = threading.Event()
def voice(socket):
try:
socket.send(json.dumps({"type": "session.created"}))
if socket.ping().wait(3):
keepalive.set()
for frame in socket:
received.append(frame)
socket.send(frame)
finally:
closed.set()
with serve(voice, "127.0.0.1", 0) as upstream:
thread = threading.Thread(target=upstream.serve_forever, daemon=True)
thread.start()
self.addCleanup(thread.join, 5)
target = f"ws://127.0.0.1:{upstream.socket.getsockname()[1]}/v1/realtime"
with patch.object(server, "S2S_PROXY_TARGET", target), TestClient(server.app) as client:
config = client.get("/api/config", headers={"host": "localhost:19420"}).json()
self.assertEqual(config["s2sUrl"], "ws://localhost:19420/v1/realtime")
with client.websocket_connect("/v1/realtime") as socket:
self.assertEqual(socket.receive_json(), {"type": "session.created"})
update = {"type": "session.update", "session": {"instructions": "Local fixture"}}
socket.send_json(update)
self.assertEqual(socket.receive_json(), update)
socket.send_bytes(b"\x00\x01\x02\x03")
self.assertEqual(socket.receive_bytes(), b"\x00\x01\x02\x03")
self.assertTrue(keepalive.wait(3))
self.assertTrue(closed.wait(3), "Client disconnect must close the upstream WebSocket")
self.assertEqual(len(received), 2)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -58,7 +58,7 @@ if [ ! -f "$REQ_FILE" ]; then
exit 1 exit 1
fi fi
if ! python -c "import fastapi, uvicorn, httpx, huggingface_hub, dotenv, websockets" >/dev/null 2>&1; then if ! python -c "import fastapi, uvicorn, httpx, dotenv, websockets" >/dev/null 2>&1; then
echo "Installing dependencies from s2s-client/requirements.txt..." echo "Installing dependencies from s2s-client/requirements.txt..."
python -m pip install -r "$REQ_FILE" python -m pip install -r "$REQ_FILE"
fi fi