refactor(api): scope uv to s2s-client and validate compatible responses

This commit is contained in:
2026-10-06 18:23:08 -03:00
parent 92c53dc7ca
commit f8d5b7857a
39 changed files with 316 additions and 67 deletions
@@ -0,0 +1,147 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from s2s_client.api.responses import validated_json_response
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
from s2s_client.models import requests as models_requests
from s2s_client.models.responses import ToolCatalogResponse, ToolExecutionResponse
from s2s_client.providers import apipilot as providers_apipilot
router = APIRouter()
@router.get("/api/apipilot/tools", response_model=ToolCatalogResponse)
async def apipilot_list_tools(
request: Request,
include_unauthorized: bool = True,
refresh: bool = False,
) -> JSONResponse:
"""Public proxy for ApiPilot tool discovery (guide §2.1).
Forwards Authorization / x-demo-session headers from the caller to the
upstream backend verbatim and returns the list of `ToolDefinition` records
exactly as documented:
{ "name": "...", "description": "...", "parameters": {...}, "requires_auth": true/false }
A short in-process TTL cache is applied (default 5 min, configurable via
S2S_APIPILOT_TTL_SEC). The client may force a refresh via `?refresh=true`
(e.g. after a user just logged in and previously saw only requires_auth tools)."""
if not settings.S2S_APIPILOT_TOOLS_URL:
return validated_json_response(
model=ToolCatalogResponse,
status_code=501,
content={
"success": False,
"data": None,
"error": {
"code": "APIPILOT_NOT_CONFIGURED",
"message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
"status_code": 501,
"raw": None,
},
},
)
auth, demo = providers_apipilot._apipilot_parse_authorization(request)
try:
tools = await providers_apipilot._apipilot_fetch_tools(
auth_header=auth,
demo_session=demo,
include_unauthorized=bool(include_unauthorized),
force_refresh=bool(refresh),
)
except HTTPException as exc:
# Normalize to envelope-style payload so downstream code is uniform.
detail = (
exc.detail
if isinstance(exc.detail, dict)
else {
"code": "APIPILOT_ERROR",
"message": str(exc.detail) if exc.detail else "Error",
"status_code": exc.status_code,
"raw": None,
}
)
return validated_json_response(
model=ToolCatalogResponse,
status_code=exc.status_code,
content={"success": False, "data": None, "error": detail},
)
app_logging.logger.info(
"[APIPILOT] /api/apipilot/tools OK tools=%d include_unauthorized=%s refresh=%s",
len(tools),
bool(include_unauthorized),
bool(refresh),
)
return validated_json_response(
{
"success": True,
"data": {
"count": len(tools),
"include_unauthorized": bool(include_unauthorized),
"tools": tools,
},
"error": None,
},
ToolCatalogResponse,
)
@router.post("/api/apipilot/execute", response_model=ToolExecutionResponse)
async def apipilot_execute(request: Request, body: models_requests.ApipilotExecuteRequest) -> JSONResponse:
"""Public proxy for ApiPilot /execute (guide §2.2).
Accepts `{name, arguments}`, runs the mandatory PRE-CHECK flow (fresh tool
discovery, `requires_auth` gate, strict JSON-Schema argument validation),
forwards the request to the upstream backend, and returns the envelope
`{success, data, error}`.
Show only `error.message` to the end user. `error.code`, `error.status_code`
and `error.raw` are for internal auditing (guide §2.2, recommended UI errors)."""
if not settings.S2S_APIPILOT_TOOLS_URL:
return validated_json_response(
model=ToolExecutionResponse,
status_code=501,
content={
"success": False,
"data": None,
"error": {
"code": "APIPILOT_NOT_CONFIGURED",
"message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
"status_code": 501,
"raw": None,
},
},
)
auth, demo = providers_apipilot._apipilot_parse_authorization(request)
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=API_ENDPOINT_EXECUTE name=%r argsKeys=%s authReceived=%s",
(body.name or ""),
sorted(list((body.arguments or {}).keys())) if isinstance(body.arguments, dict) else [],
("Y" if auth or demo else "none"),
)
envelope = await providers_apipilot._apipilot_execute_tool(
auth_header=auth,
demo_session=demo,
name=body.name,
arguments=body.arguments if isinstance(body.arguments, dict) else {},
)
status_code = 200
if (not envelope.get("success")) and isinstance(envelope.get("error"), dict):
code = envelope["error"].get("status_code")
try:
code_int = int(code)
if 400 <= code_int < 599:
status_code = code_int
except Exception:
pass
app_logging.logger.info(
"[APIPILOT] /api/apipilot/execute name=%s success=%s status=%s",
body.name,
envelope.get("success"),
status_code,
)
return validated_json_response(envelope, ToolExecutionResponse, status_code=status_code)
+289
View File
@@ -0,0 +1,289 @@
from __future__ import annotations
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from s2s_client.api.responses import validated_json_response
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
from s2s_client.models.responses import ReportsResponse, UserProfileResponse
from s2s_client.providers import caller as providers_caller
from s2s_client.services import context as services_context
router = APIRouter()
@router.get("/api/user/phone", response_model=UserProfileResponse)
async def user_by_phone(phoneNumber: str) -> JSONResponse:
"""HTTP passthrough: fetches caller identity / profile from the external
info endpoint set via VOCERO_USER_PHONE_AUTH_URL and forwards the raw
JSON to the browser.
The proxy NEVER inspects this payload nor injects it into the realtime
WebSocket session. It is only used by:
- the browser UI to render a caller name header (#ident-user-name)
- the /api/reasoning HTTP body user_context field (browser's choice).
When VOCERO_USER_PHONE_AUTH_URL is unset we return 503 so the UI can
silently skip identity loading (backward compatible with generic deploys).
When S2S_LOGIN_BY_PHONE is set, the proxy first performs a login-by-phone
POST with the caller phone and (if a bearer token is extracted) forwards
it as Authorization: Bearer on the subsequent profile GET.
"""
if not settings.VOCERO_USER_PHONE_AUTH_URL:
raise HTTPException(
status_code=503,
detail="VOCERO_USER_PHONE_AUTH_URL no está configurado en este despliegue.",
)
phone = (phoneNumber or "").strip()
if not phone:
raise HTTPException(status_code=400, detail="Se requiere el campo phoneNumber.")
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set).
login_token = None
if settings.S2S_LOGIN_BY_PHONE:
login_token, _login_obj = await providers_caller._login_by_phone(phone)
target_url = f"{settings.VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone})}"
app_logging.logger.debug(
"[EXT][USER-BY-PHONE] GET upstream=%s phoneLen=%d bearer=%s",
settings.VOCERO_USER_PHONE_AUTH_URL,
len(phone),
"yes" if login_token else "no",
)
try:
headers = {}
if login_token:
headers["Authorization"] = f"Bearer {login_token}"
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http:
resp = await http.get(target_url, headers=headers or None)
except httpx.RequestError as exc:
app_logging.logger.warning("[EXT][USER-BY-PHONE] unreachable: %r", exc)
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de información del usuario.") # noqa: B904
# Forward the upstream status + JSON verbatim; never rewrite the payload.
if resp.status_code >= 500:
app_logging.logger.warning(
"[EXT][USER-BY-PHONE] upstream HTTP %s: %.200s",
resp.status_code,
resp.text,
)
try:
payload = resp.json()
except Exception:
raise HTTPException( # noqa: B904
status_code=502,
detail="El servicio de información del usuario devolvió un JSON no válido.",
)
return validated_json_response(
payload, UserProfileResponse, status_code=resp.status_code if resp.status_code != 204 else 200
)
@router.get("/api/user/reports", response_model=ReportsResponse)
async def user_reports(phoneNumber: str | None = None) -> JSONResponse:
"""Fetch + normalize the scheduled reports list for the caller UI.
Mirrors the same sequence used in the WebSocket connect handler and
/api/reasoning:
0. login-by-phone (optional) → bearer token
1. GET S2S_REPORT_LIST with ?phoneNumber= (if phone provided) + Bearer auth
2. run the same resolver used for prompt injection: filter 3 fields,
sort DESC, build hints + entries → JSON returned verbatim so the
browser can render a "reports loaded" summary in the UI header.
Returns 503 when S2S_REPORT_LIST is not configured (generic deploy with no
reports feature enabled) so the browser silently skips UI rendering.
Response shape on success:
{
"count": N,
"wrappedLen": X,
"hints": [ "• Report #1: '<label>' — scheduled on <date>", ... ],
"entries": [
{"sourceScheduleLabel": "...", "date": "...", "podcastScript": "..."},
...
]
}
USER FIX 2026-09-11: remove mass injection of scheduled reports.
• Even this BROWSER-FACING endpoint (/api/user/reports) now honours the new
S2S_INJECT_REPORTS default=False gate: when S2S_INJECT_REPORTS is not
explicitly true, we return a polite 403 + empty response — the endpoint
does NOT execute any upstream S2S_REPORT_LIST HTTP call, does NOT expose
any podcastScript bodies, and does NOT emit entries/hints. This avoids
a subtle loophole where the browser could still fetch scheduled reports
through /api/user/reports while the LLM prompt paths were gated.
"""
# ── MASS-INJECTION KILL SWITCH (user request) ─────────────────────────
if not settings.S2S_INJECT_REPORTS:
return validated_json_response(
model=ReportsResponse,
status_code=200,
content={
"count": 0,
"wrappedLen": 0,
"hints": [],
"entries": [],
"skipped": True,
"reason": "S2S_INJECT_REPORTS está deshabilitado en este despliegue; no se muestran reportes programados en la interfaz ni se envían al modelo.",
},
)
# ───────────────────────────────────────────────────────────────────────
if not settings.S2S_REPORT_LIST:
raise HTTPException(
status_code=503,
detail="S2S_REPORT_LIST no está configurado en este despliegue.",
)
phone = (phoneNumber or "").strip() or None
# 0) Login-by-phone if enabled. Bearer token used only if login succeeded
# (same rule as WS connect / reasoning handlers — never-fail).
login_token = None
if settings.S2S_LOGIN_BY_PHONE and phone:
login_token, _login_obj = await providers_caller._login_by_phone(phone)
reports_raw = await providers_caller._fetch_reports_list(phone, login_token)
# Re-run the same resolver used for prompt injection → identical logic
# guarantees UI and LLM see exactly the same filtered/sorted list.
ctx_str = services_context._resolve_reports_context(reports_raw)
if not ctx_str or not isinstance(reports_raw, list):
return validated_json_response(
{
"count": 0,
"wrappedLen": 0,
"hints": [],
"entries": [],
"skipped": True,
"reason": "Lista vacía, error del servicio de origen o ningún contenido disponible.",
},
ReportsResponse,
)
# Rebuild hints + normalized entries with the exact filtered output
items_list: list = reports_raw
# Re-derive filtered + sorted list (same as _resolve_reports_context) so
# we don't accidentally expose raw unfiltered upstream payload to the UI.
filtered: list[dict] = []
for item in items_list:
if not isinstance(item, dict):
continue
cleaned: dict = {}
label_val: object = None
if item.get("sourceScheduleLabel") is not None:
label_val = item["sourceScheduleLabel"]
elif isinstance(item.get("sourceSchedule"), dict):
ss = item["sourceSchedule"]
for k in ("label", "name", "title"):
if ss.get(k) is not None:
label_val = ss[k]
break
if label_val is not None:
cleaned["sourceScheduleLabel"] = label_val
date_val: object = None
if item.get("date") is not None:
date_val = item["date"]
elif isinstance(item.get("sourceSchedule"), dict):
ss = item["sourceSchedule"]
for k in ("date", "scheduledAt", "publishedAt"):
if ss.get(k) is not None:
date_val = ss[k]
break
if date_val is not None:
cleaned["date"] = date_val
script_val: object = None
for k in ("podcastScript", "script", "text", "content"):
if item.get(k) is not None:
script_val = item[k]
break
if script_val is not None:
cleaned["podcastScript"] = script_val
if cleaned:
filtered.append(cleaned)
filtered.sort(key=lambda r: services_context._parse_date_key(r.get("date")))
# 2026-09-11 user fix: configure report quick-hint limits through .env.
# Apply the SAME cap used by the LLM prompt resolver: after sort by newest,
# keep only first S2S_REPORTS_QUICK_HINT_MAX entries. This keeps counts between
# /api/user/reports UI JSON and the LLM prompt identical — avoids confusing
# mismatches where the UI shows 20 hints but the prompt only saw 5.
total_raw_ui = len(filtered)
cap_ui = max(1, int(settings.S2S_REPORTS_QUICK_HINT_MAX or 5))
truncated_ui_note: str | None = None
if total_raw_ui > cap_ui:
filtered = filtered[:cap_ui]
truncated_ui_note = (
f"Se muestran los {cap_ui} reportes más recientes de un total de {total_raw_ui}. "
"Ajuste la variable de entorno S2S_REPORTS_QUICK_HINT_MAX para cambiar la cantidad."
)
import datetime as _dt
# Deduplicate by label for the UI JSON response only.
# NOTE: the LLM prompt injection uses the full non-deduped list (different
# dates = different content even when the scheduled report name repeats).
# We only dedup entries_out, hints_list, and entries for the browser payload.
seen_labels: set[str] = set()
dedup_filtered: list[dict] = []
for r in filtered:
lbl_raw = r.get("sourceScheduleLabel")
if lbl_raw is None:
# Items with no label are kept as-is (can't meaningfully dedupe)
dedup_filtered.append(r)
continue
key = str(lbl_raw).strip()
if not key:
dedup_filtered.append(r)
continue
if key in seen_labels:
continue
seen_labels.add(key)
dedup_filtered.append(r)
hints_list: list[str] = []
entries_out: list[dict] = []
for idx, r in enumerate(dedup_filtered, start=1):
entries_out.append(r)
label_piece = str(r["sourceScheduleLabel"]).strip() if r.get("sourceScheduleLabel") else ""
date_piece = ""
if r.get("date") is not None:
raw_d = r["date"]
try:
if isinstance(raw_d, (int, float)) and not isinstance(raw_d, bool):
ts_sec = float(raw_d)
if ts_sec > 1e12:
ts_sec = ts_sec / 1000.0
dt = _dt.datetime.fromtimestamp(ts_sec, tz=_dt.UTC)
date_piece = dt.strftime("%Y-%m-%d %H:%M UTC")
else:
s = str(raw_d).strip()
if "T" in s and len(s) >= 16:
date_piece = s.replace("T", " ")[:16]
else:
date_piece = s
except Exception:
date_piece = str(raw_d)[:32]
if label_piece and date_piece:
hints_list.append(f"• Reporte #{idx}: '{label_piece}' — programado para {date_piece}")
elif label_piece:
hints_list.append(f"• Reporte #{idx}: '{label_piece}'")
elif date_piece:
hints_list.append(f"• Reporte #{idx}: (sin título) — programado para {date_piece}")
resp_body = {
"count": len(entries_out),
"rawCount": total_raw_ui,
"cap": cap_ui,
"wrappedLen": len(ctx_str),
"hints": hints_list,
# Strip the long podcastScript from the summary JSON to keep the
# browser response lightweight (UI only needs label/date).
"entries": [
{
"sourceScheduleLabel": r.get("sourceScheduleLabel"),
"date": r.get("date"),
}
for r in entries_out
],
}
if truncated_ui_note is not None:
resp_body["truncated"] = True
resp_body["note"] = truncated_ui_note
return validated_json_response(resp_body, ReportsResponse)
@@ -0,0 +1,55 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from s2s_client.api.responses import validated_json_response
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
from s2s_client.core import urls as core_urls
from s2s_client.models.responses import ConfigResponse
router = APIRouter()
def _effective_s2s_url(request: Request) -> str:
return settings.SPEECH_TO_SPEECH_URL or core_urls._proxy_public_url(request)
@router.get("/api/config", response_model=ConfigResponse)
async def config(request: Request) -> JSONResponse:
"""Client bootstrap: whether web search is available, whether the deploy runs
behind a load balancer (so the browser uses the /api/session proxy),
and whether the user may instead set a direct
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
new `/api/apipilot/tools` and `/api/apipilot/execute` proxy endpoints (see
docs/client/tool-calling.md) that the browser may call directly instead of
hardcoding upstream tool URLs / names."""
app_logging.logger.debug(
"[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?"
)
primary_prompt = settings.S2S_PRIMARY_PROMPT
out = {
"search": bool(settings.SERPER_KEY),
"lb": bool(settings.LOAD_BALANCER_URL),
"allowDirect": not settings.LOAD_BALANCER_URL,
"s2sUrl": _effective_s2s_url(request),
"primaryPrompt": primary_prompt,
"reasoningType": settings.S2S_REASONING_TYPE,
"injectProfile": settings.S2S_INJECT_PROFILE,
"debugPrompt": settings.S2S_DEBUG_PROMPT,
"startSessionMode": settings.S2S_START_SESSION_MODE,
"theme": settings.S2S_THEME,
"apipilot": bool(settings.S2S_APIPILOT_TOOLS_URL),
}
app_logging.logger.debug(
"[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s apipilot=%s",
len(primary_prompt),
settings.S2S_REASONING_TYPE,
settings.S2S_START_SESSION_MODE or "-",
settings.S2S_THEME or "-",
out["apipilot"],
)
return validated_json_response(out, ConfigResponse)
@@ -0,0 +1,92 @@
from __future__ import annotations
import json
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from s2s_client.api.responses import validated_json_response
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
from s2s_client.models import requests as models_requests
from s2s_client.models.responses import SearchResponse
router = APIRouter()
@router.post("/api/search", response_model=SearchResponse)
async def search(req: models_requests.SearchRequest) -> JSONResponse:
"""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="La consulta está vacía.")
key = (req.key or "").strip() or settings.SERPER_KEY
has_user_key = bool((req.key or "").strip())
if not key:
# No server key and the user didn't supply one — search is unavailable.
raise HTTPException(status_code=503, detail="La búsqueda no está configurada.")
headers = {"X-API-KEY": key, "Content-Type": "application/json"}
payload = {"q": query, "num": settings.MAX_RESULTS}
app_logging.logger.debug(
"[EXT][SERPER] search POST %s query=%s userKey=%s num=%d",
settings.SERPER_URL,
json.dumps(query, ensure_ascii=True)[:200],
has_user_key,
settings.MAX_RESULTS,
)
try:
async with httpx.AsyncClient(timeout=12.0) as http:
resp = await http.post(settings.SERPER_URL, headers=headers, json=payload)
except httpx.RequestError as exc:
app_logging.logger.warning("Serper unreachable: %r", exc)
raise HTTPException(status_code=502, detail="No se puede conectar con el proveedor de búsqueda.") # noqa: B904
app_logging.logger.debug(
"[EXT][SERPER] search RESP status=%s len=%dB",
resp.status_code,
len(resp.content or b""),
)
if resp.status_code != 200:
# Serper's error body carries the real reason (e.g. "Not enough
# credits") and contains no key, so it's safe to log and relay.
body = resp.text[:300]
app_logging.logger.warning("Serper error %s: %s", resp.status_code, body)
msg = None
try:
msg = resp.json().get("message")
except Exception:
pass
detail = f"Error del proveedor de búsqueda ({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 [])[: settings.MAX_RESULTS]:
results.append(
{
"title": item.get("title", ""),
"snippet": item.get("snippet", ""),
"url": item.get("link", ""),
}
)
# A direct answer when Google has one — saves the model a hop.
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
app_logging.logger.debug(
"[EXT][SERPER] search PARSED hasAnswer=%s resultCount=%d titles=%s",
bool(answer),
len(results),
[r.get("title", "")[:40] for r in results],
)
return validated_json_response({"query": query, "answer": answer, "results": results}, SearchResponse)
@@ -0,0 +1,185 @@
from __future__ import annotations
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from s2s_client.api.responses import validated_json_response
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
from s2s_client.models.requests import QueueEndPayload
from s2s_client.models.responses import AllocationResponse, QueueCancellationResponse
router = APIRouter()
@router.post("/api/session", response_model=AllocationResponse)
async def session(request: Request) -> JSONResponse:
"""Relay a session grant or queue ticket, keeping the LB address server-side.
The grant's `connect_url` contains the compute host and short-lived token the
browser needs to connect directly to the allocated speech service."""
client_ip = request.client.host if request.client else "?"
if not settings.LOAD_BALANCER_URL:
# No LB configured — this deploy is direct-mode only; the browser should
# never call this. 404 so it's indistinguishable from a missing route.
raise HTTPException(status_code=404, detail="No encontrado.")
app_logging.logger.debug("[EXT][LB] session: client=%s", client_ip)
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/session"
app_logging.logger.debug("[EXT][LB] session POST %s (handshake)", url)
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:
app_logging.logger.warning("Load balancer unreachable: %r", exc)
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de voz.") # noqa: B904
app_logging.logger.debug(
"[EXT][LB] session RESP status=%s len=%dB",
lb.status_code,
len(lb.content or b""),
)
# The queue is full: the LB replies 503 {state:"at_capacity"}. Relay it as-is
# so the client shows a soft "try again shortly", not a hard error.
if lb.status_code == 503:
body = _safe_json(lb)
if body.get("state") == "at_capacity":
app_logging.logger.debug("[EXT][LB] session: at_capacity — queue full")
return validated_json_response({"state": "at_capacity"}, AllocationResponse, status_code=503)
if lb.status_code != 200:
# The LB's error body may name the reason (e.g. capacity); it carries no
# secret, so relay a trimmed copy.
app_logging.logger.warning("Session handshake failed %s: %s", lb.status_code, lb.text[:300])
raise HTTPException(status_code=502, detail=f"No se pudo establecer la sesión ({lb.status_code}).")
data = lb.json()
state = data.get("state", "?")
connect_url = (data.get("connect_url") or data.get("connectUrl") or "")[:120]
session_id = data.get("session_id") or data.get("sessionId")
app_logging.logger.debug(
"[EXT][LB] session PARSED state=%s sessionId=%s hasConnectUrl=%s connectUrl=%s",
state,
session_id,
bool(connect_url),
connect_url,
)
# Busy pool: the LB queued us. Relay the ticket for subsequent polling.
if data.get("state") == "queued":
app_logging.logger.debug(
"[EXT][LB] session: queued queueId=%s position=%s",
data.get("queue_id") or data.get("queueId"),
data.get("position"),
)
return validated_json_response(data, AllocationResponse)
@router.get("/api/queue/{queue_id}", response_model=AllocationResponse)
async def queue_status(queue_id: str, request: Request) -> JSONResponse:
"""Relay a waiting ticket's position or its newly claimed session grant."""
client_ip = request.client.host if request.client else "?"
if not settings.LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="No encontrado.")
app_logging.logger.debug(
"[EXT][LB] queue poll: client=%s queueId=%s",
client_ip,
queue_id[:16],
)
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
app_logging.logger.debug("[EXT][LB] queue GET %s", url)
try:
async with httpx.AsyncClient(timeout=15.0) as http:
lb = await http.get(url)
except httpx.RequestError as exc:
app_logging.logger.warning("Load balancer unreachable: %r", exc)
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de voz.") # noqa: B904
app_logging.logger.debug(
"[EXT][LB] queue RESP status=%s len=%dB",
lb.status_code,
len(lb.content or b""),
)
if lb.status_code == 404:
# Ticket unknown/expired (reaped after we stopped polling). Tell the client
# to start over rather than spin.
app_logging.logger.debug("[EXT][LB] queue: queueId=%s EXPIRED (404)", queue_id[:16])
return validated_json_response({"state": "expired"}, AllocationResponse, status_code=404)
if lb.status_code != 200:
app_logging.logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300])
raise HTTPException(status_code=502, detail=f"No se pudo consultar la cola de espera ({lb.status_code}).")
data = lb.json()
state = data.get("state", "?")
position = data.get("position")
app_logging.logger.debug(
"[EXT][LB] queue PARSED queueId=%s state=%s position=%s sessionId=%s",
queue_id[:16],
state,
position,
data.get("session_id") or data.get("sessionId"),
)
return validated_json_response(data, AllocationResponse)
@router.delete("/api/queue/{queue_id}", response_model=QueueCancellationResponse)
async def queue_leave(queue_id: str) -> JSONResponse:
"""Leave the queue from the explicit 'Leave queue' button (a real fetch)."""
if not settings.LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="No encontrado.")
app_logging.logger.debug("[EXT][LB] queue LEAVE (button) queueId=%s", queue_id[:16])
await _lb_leave(queue_id)
return validated_json_response({"ok": True}, QueueCancellationResponse)
@router.post("/api/queue/end", response_model=QueueCancellationResponse)
async def queue_end(request: Request) -> JSONResponse:
"""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 settings.LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="No encontrado.")
qid = await _queue_id(request)
if qid:
app_logging.logger.debug("[EXT][LB] queue LEAVE (teardown) queueId=%s", qid[:16])
await _lb_leave(qid)
return validated_json_response({"ok": True}, QueueCancellationResponse)
async def _lb_leave(queue_id: str) -> None:
"""Best-effort: tell the LB to drop a waiting ticket."""
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
app_logging.logger.debug("[EXT][LB] queue DELETE %s", url)
try:
async with httpx.AsyncClient(timeout=5.0) as http:
resp = await http.delete(url)
app_logging.logger.debug(
"[EXT][LB] queue DELETE RESP queueId=%s status=%s",
queue_id[:16],
resp.status_code,
)
except httpx.RequestError as exc:
app_logging.logger.warning("Queue leave failed: %r", exc)
def _safe_json(response: httpx.Response) -> dict[str, Any]:
try:
body = response.json()
except Exception:
return {}
return body if isinstance(body, dict) else {}
async def _queue_id(request: Request) -> Any:
"""Pull `queueId` from a JSON body, tolerating sendBeacon's blob posts."""
try:
data = await request.json()
except Exception:
return ""
return QueueEndPayload.model_validate(data).queueId if isinstance(data, dict) else ""