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
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
import os
from dotenv import load_dotenv
from s2s_client.core import logging as app_logging
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
load_dotenv(os.path.join(ROOT_DIR, ".env"), override=False)
app_logging.logger.debug("Loaded .env from %s", ROOT_DIR)
def _normalize_reasoning_type(value: str) -> str:
normalized = (value or "").strip().strip('"').strip("'").replace("-", "_").upper()
if normalized == "OPENAI":
return "OPENAI"
if normalized in {"NODE_RED", "NODERED"}:
return "NODE_RED"
return "PROXY"
SERPER_KEY = os.environ.get("SERPER_API_KEY", "").strip()
S2S_PRIMARY_PROMPT = os.environ.get("S2S_PRIMARY_PROMPT", "").strip()
if S2S_PRIMARY_PROMPT:
S2S_PRIMARY_PROMPT = S2S_PRIMARY_PROMPT.replace("\\n", "\n")
# External user-by-phone info endpoint. When set, GET /api/user/phone proxies
# to this URL (passing ?phoneNumber=) and forwards the JSON payload to the
# browser so the UI can render caller identity / caller context.
VOCERO_USER_PHONE_AUTH_URL = os.environ.get("VOCERO_USER_PHONE_AUTH_URL", "").strip().rstrip("/")
# Feature flag: when "true" (case-insensitive) => injects the resolved caller
# profile into the realtime session (patch session.update client->upstream,
# append profile to the LLM system instructions prompt instructions, and prepend it to the
# reasoning user_context for the /api/reasoning HTTP body.
# Default = disabled (generic deploy; pure passthrough; all characterization lives
# in the backend RAG per original architectural decision.)
S2S_INJECT_PROFILE = (os.environ.get("S2S_INJECT_PROFILE", "") or "").strip().lower() in {"1", "true", "yes", "on"}
# Endpoint URL for fetching the latest per-caller conversation summary, used when
# both S2S_INJECT_PROFILE=true AND S2S_LATEST_SUMMARY is set. Completely
# generic: the proxy performs a plain GET S2S_LATEST_SUMMARY?phoneNumber=<x>
# and expects JSON back (optionally wrapped as {"result": <value>}). Empty
# string means the feature is off (no network call, nothing injected).
S2S_LATEST_SUMMARY = (os.environ.get("S2S_LATEST_SUMMARY", "") or "").strip()
# Static / hardcoded latest-summary text. When set (non-empty string), it takes
# PRIORITY over S2S_LATEST_SUMMARY: the proxy SKIPS the HTTP fetch entirely and
# injects this raw plain-text value directly into the prompt as the latest
# conversation summary. Designed for cases where the operator wants a clean,
# essential, pre-cleaned summary snippet without depending on an upstream API.
# Bypasses all HTTP logic (no login-by-phone bearer forwarding, no timeout, no
# JSON unwrapping) — the string is passed as-is to _resolve_latest_summary_context
# (which still applies the 1600-char cap + the LATEST_SUMMARY_HINT wrapper).
S2S_LATEST_SUMMARY_TEXT = (os.environ.get("S2S_LATEST_SUMMARY_TEXT", "") or "").strip()
if S2S_LATEST_SUMMARY_TEXT:
# Interpret \n escape sequences so multi-line summaries can be written as a
# single env line (matches S2S_PRIMARY_PROMPT convention above).
S2S_LATEST_SUMMARY_TEXT = S2S_LATEST_SUMMARY_TEXT.replace("\\n", "\n")
# Optional: login-by-phone endpoint. When set (and S2S_INJECT_PROFILE=true and
# phoneNumber is available), before fetching user profile / latest summary the proxy
# first performs a POST S2S_LOGIN_BY_PHONE with {"phoneNumber": "..."} JSON body,
# extracts an auth token from the response (if any), and forwards it as
# Authorization: Bearer <token> for subsequent profile / summary / reports HTTP calls.
S2S_LOGIN_BY_PHONE = (os.environ.get("S2S_LOGIN_BY_PHONE", "") or "").strip().rstrip("/")
# Optional: list of scheduled reports / podcasts endpoint. When set (and S2S_INJECT_PROFILE=true)
# the proxy performs a plain GET S2S_REPORT_LIST, unwraps the JSON array (or {"result": [...]}),
# filters per-item fields (sourceScheduleLabel, date, podcastScript), sorts by date DESC,
# and injects the compiled block into the LLM instructions / user_context between
# the caller profile block and the latest conversation summary block.
S2S_REPORT_LIST = (os.environ.get("S2S_REPORT_LIST", "") or "").strip().rstrip("/")
# Feature flag: when "true" (case-insensitive) => actually injects the REPORT LIST
# contents (podcastScript) into the LLM instructions / context. When "false" (DEFAULT)
# the proxy NEVER injects scheduled reports into the prompt — not even the TOC — and
# skips the upstream S2S_REPORT_LIST network call entirely, fixing the user issue:
# "All reports are currently injected with their complete podcastScript."
#
# Historical note (2026-09-11): before this flag existed, if S2S_INJECT_PROFILE=true AND
# S2S_REPORT_LIST was set the server eagerly fetched every scheduled report for the
# caller, serialized all their full podcastScript fields into a single block (capped at
# 12000 chars) and appended it to every LLM turn. That was a huge prompt-bloat injection
# the user explicitly flagged as unwanted. The safe default is therefore DISABLED: to
# restore mass-report-injection, operators must opt in with S2S_INJECT_REPORTS=true.
S2S_INJECT_REPORTS = (os.environ.get("S2S_INJECT_REPORTS", "") or "").strip().lower() in {"1", "true", "yes", "on"}
# Max number of scheduled reports to surface in the QUICK-HINT line block (the TOC of
# label + date shown before the serialized JSON entries). Also applied as a hard cap to
# the final filtered+serialized entries list — so the wrapped prompt block never carries
# content for more than N reports, even when the upstream API returns 20+. Configurable
# from .env; value is integer-parsed and clamped to [1, 50] (invalid values fall back to
# the default = 5, which is the user's requested "max 3-5" default range). User request
# 2026-09-11: "Limit report quick hints to 3-5 entries, configurable through .env."
try:
_S2S_REPORTS_QUICK_HINT_MAX_RAW = int((os.environ.get("S2S_REPORTS_QUICK_HINT_MAX", "") or "5").strip())
except Exception:
_S2S_REPORTS_QUICK_HINT_MAX_RAW = 5
S2S_REPORTS_QUICK_HINT_MAX = max(1, min(50, _S2S_REPORTS_QUICK_HINT_MAX_RAW))
del _S2S_REPORTS_QUICK_HINT_MAX_RAW
# Feature flag: when "true" → prepend a TODAY date + anti-hallucination guard to
# every LLM prompt and session instructions. Fixes user bug 3 2026-09-11:
# "Qwen2.5 has a training cutoff around mid-2024 and produces Chinese meta-comments
# about 2026 reports: 'Today is 2023; why are these reports dated 2026?'"
# Default = ON (safe even when empty/unset). Explicit S2S_INJECT_TODAY="false" to disable.
# Injected text contains:
# • The actual date: "TODAY is YYYY-MM-DD" (ISO, server local date).
# • Earlier reports are historical records; do not question their dates.
# • No internal meta-comments or prompt introspection.
# • No thinking leaks with GPTQ Int4: only words Vocero says aloud.
# • Retrieve report content through RAG rather than inventing it from summaries.
S2S_INJECT_TODAY = (os.environ.get("S2S_INJECT_TODAY", "") or "").strip().lower() not in {"0", "false", "no", "off"}
# Feature flag: S2S_REPORTS_INJECT_MODE controls what the server sends into the LLM
# prompt for scheduled reports. Values (case-insensitive, default="minimal"):
# • "minimal" → ONLY last N = S2S_REPORTS_QUICK_HINT_MAX items as a pure
# TOC (label + date ONLY). NO podcastScript is ever exposed. This
# the user's "Keep reports only as a MINIMAL summary"
# fix. Token budget: ~50 tokens instead of ~3200.
# • "full" → legacy behaviour: full JSON array with podcastScript
# (still capped to S2S_REPORTS_QUICK_HINT_MAX items). Used to be
# the default before 2026-09-11; kept only for explicit opt-in
# because of the user explicitly flagged the massive script injection
# as unwanted on this session.
# • "off" / "none" → no reports injection at all (same as S2S_INJECT_REPORTS=false)
# even when S2S_INJECT_REPORTS=true. Use this to keep the HTTP
# fetch running while keeping the prompt clean, or as a redundant kill.
S2S_REPORTS_INJECT_MODE = (os.environ.get("S2S_REPORTS_INJECT_MODE", "") or "").strip().lower()
if S2S_REPORTS_INJECT_MODE not in {"minimal", "full", "off", "none"}:
S2S_REPORTS_INJECT_MODE = "minimal"
# Feature flag: when "true" the proxy logs the FULL PROMPT TEXT (not just
# lengths) going to the upstream LLM/realtime backends. Includes:
# - WS session.update session.instructions (before/after patch)
# - /api/reasoning NODE_RED instructions + user_context full text
# - /api/reasonING OPENAI full system message + user transcript
# Default = off; only length summaries are logged (current behaviour).
# WARNING: setting this on will log potentially PII / sensitive caller
# profile data / user transcripts. Use only in development / debugging.
S2S_DEBUG_PROMPT = (os.environ.get("S2S_DEBUG_PROMPT", "") or "").strip().lower() in {"1", "true", "yes", "on"}
S2S_START_SESSION_MODE = (os.environ.get("S2S_START_SESSION_MODE", "") or "").strip().upper()
S2S_THEME = (os.environ.get("S2S_THEME", "") or "").strip().lower()
if S2S_THEME and S2S_THEME not in {"white", "dark"}:
S2S_THEME = ""
S2S_REASONING_TYPE = _normalize_reasoning_type(os.environ.get("S2S_REASONING_TYPE", "PROXY"))
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip()
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").strip().rstrip("/")
OPENAI_REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-4.1-mini").strip()
OPENAI_ORGANIZATION = os.environ.get("OPENAI_ORGANIZATION", "").strip()
OPENAI_PROJECT = os.environ.get("OPENAI_PROJECT", "").strip()
# ──────────────────────────────────────────────────────────────────────────────
# APIPILOT — Backend AquaPortal dynamic-tool bridge (docs/client/tool-calling.md)
# ──────────────────────────────────────────────────────────────────────────────
# Base URL for ApiPilot dynamic tool discovery and execution. When configured,
# the proxy sends GET <url>/tools with include_unauthorized=true (guide §2.1)
# to discover tools, and POST <url>/execute (§2.2) to invoke one. The trailing
# slash is optional; the proxy normalizes it with rstrip("/") below.
#
# Example: S2S_APIPILOT_TOOLS_URL=https://aquachile.azurianlab.com/api/v1/apipilot
S2S_APIPILOT_TOOLS_URL = os.environ.get("S2S_APIPILOT_TOOLS_URL", "").strip().rstrip("/")
# Explicit execution URL override (guide §2.2). When set, it takes precedence
# over S2S_APIPILOT_TOOLS_URL for POST execution calls. Remove trailing slashes
# and append "/execute" only if the URL does not already end with that suffix.
# Example 1 (base): S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot
# → POST https://aquachile.azurianlab.com/api/v1/apipilot/execute
# Example 2: S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot/tools
# → POST https://aquachile.azurianlab.com/api/v1/apipilot/tools/execute
S2S_APIPILOT_TOOLS_EXECUTE_URL = os.environ.get("S2S_APIPILOT_TOOLS_EXECUTE_URL", "").strip().rstrip("/")
# Optional fixed bearer token, used only when the caller supplies no session
# token (Authorization / x-demo-session). Useful for staging service tokens.
S2S_APIPILOT_SERVICE_TOKEN = os.environ.get("S2S_APIPILOT_SERVICE_TOKEN", "").strip()
# 2026-09-25 SUPER API KEY (TEMPORARY AQUAPORTAL AUTHENTICATION BYPASS).
# When set, send all three header forms for maximum compatibility:
# 1) X-API-Key: <key>
# 2) Authorization: Bearer <key>
# 3) x-demo-session: <key>
# Highest priority; completely disables the unnecessary login_by_phone call.
# Clear the value to restore standard login.
S2S_APIPILOT_API_KEY = os.environ.get("S2S_APIPILOT_API_KEY", "").strip().strip("\"'")
# Tool discovery cache TTL in seconds. The guide recommends a five-minute
# client cache; the server uses the same default: 300 seconds.
try:
_APIPILOT_TTL_RAW = int((os.environ.get("S2S_APIPILOT_TTL_SEC", "") or "300").strip())
except Exception:
_APIPILOT_TTL_RAW = 300
APIPILOT_CACHE_TTL_SEC = max(15, min(3600, _APIPILOT_TTL_RAW))
del _APIPILOT_TTL_RAW
# HTTP timeout for ApiPilot discovery and execution calls, in seconds.
try:
_APIPILOT_TO_RAW = float((os.environ.get("S2S_APIPILOT_TIMEOUT_SEC", "") or "15.0").strip())
except Exception:
_APIPILOT_TO_RAW = 15.0
APIPILOT_HTTP_TIMEOUT = max(1.0, min(120.0, _APIPILOT_TO_RAW))
del _APIPILOT_TO_RAW
# Node-RED reasoning engine endpoint (NODE_RED mode).
# Examples: http://localhost:1881 or http://s2s-reasoning:1880 (Docker network).
NODE_RED_REASONING_URL = os.environ.get("S2S_REASONING_NODE_RED_URL", "").strip().rstrip("/")
# Speech-to-speech load balancer URL. When set, the browser POSTs /api/session
# (which proxies <lb>/session here, server-side) and connects to the URL the LB
# returns (the original flow). The LB address itself is never sent to the browser.
# When empty, the user may instead set a direct s2s server URL in Settings and the
# browser connects to it straight (no load balancer).
LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip()
# Direct s2s server URL pinned by the deploy. Takes priority over the load
# balancer: when set, LB allocation and queuing are disabled, and the browser
# connects to this URL directly. Unlike the LB address it is NOT a secret —
# /api/config sends it to the client, which
# shows it read-only in Settings.
SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip()
# Optional same-origin WebSocket relay target. When set, the browser dials this
# app over ws(s)://<this-app>/v1/realtime and the server relays frames to the
# upstream speech-to-speech endpoint, keeping the internal host private.
S2S_PROXY_TARGET = os.environ.get("S2S_PROXY_TARGET", "").strip()
if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET:
LOAD_BALANCER_URL = ""
SERPER_URL = "https://google.serper.dev/search"
# Cap results so the tool output stays small enough to feed back to the model.
MAX_RESULTS = 5
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
# Resolve the execution URL for the startup banner using the same normalization
# as _apipilot_resolve_execute_url.
def _resolve_exec_url_for_startup_banner() -> str:
base = (settings.S2S_APIPILOT_TOOLS_EXECUTE_URL or settings.S2S_APIPILOT_TOOLS_URL).strip().rstrip("/")
if not base:
return ""
if base.lower().endswith("/execute"):
return base
return f"{base}/execute"
_STARTUP_APIPILOT_EXEC_URL = _resolve_exec_url_for_startup_banner()
del _resolve_exec_url_for_startup_banner
app_logging.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 apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s",
settings.S2S_REASONING_TYPE,
settings.NODE_RED_REASONING_URL or "-",
bool(settings.SERPER_KEY),
bool(settings.LOAD_BALANCER_URL),
bool(settings.S2S_PROXY_TARGET),
bool(settings.SPEECH_TO_SPEECH_URL),
bool(settings.VOCERO_USER_PHONE_AUTH_URL),
bool(settings.S2S_REPORT_LIST),
settings.S2S_INJECT_REPORTS,
settings.S2S_REPORTS_QUICK_HINT_MAX,
settings.S2S_REPORTS_INJECT_MODE,
settings.S2S_INJECT_TODAY,
bool(settings.S2S_LATEST_SUMMARY),
bool(settings.S2S_LATEST_SUMMARY_TEXT),
bool(settings.S2S_LOGIN_BY_PHONE),
settings.S2S_INJECT_PROFILE,
settings.S2S_DEBUG_PROMPT,
settings.S2S_START_SESSION_MODE or "-",
settings.S2S_THEME or "-",
settings.S2S_APIPILOT_TOOLS_URL or "-",
(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL + f"(resolved:{_STARTUP_APIPILOT_EXEC_URL})")
if settings.S2S_APIPILOT_TOOLS_EXECUTE_URL
else "-",
"Y" if bool(settings.S2S_APIPILOT_SERVICE_TOKEN) else "-",
settings.APIPILOT_CACHE_TTL_SEC,
settings.APIPILOT_HTTP_TIMEOUT,
"Y" if (bool(settings.S2S_APIPILOT_TOOLS_URL) or bool(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL)) else "-",
(
"YES head="
+ (
settings.S2S_APIPILOT_API_KEY[:6] + "..."
if len(settings.S2S_APIPILOT_API_KEY) > 6
else settings.S2S_APIPILOT_API_KEY
)
+ " (3 FORMS!)"
)
if settings.S2S_APIPILOT_API_KEY
else "-",
)
del _STARTUP_APIPILOT_EXEC_URL
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import logging
import os
logger = logging.getLogger("s2s")
_logging_configured = False
if not _logging_configured:
_handler = logging.StreamHandler()
_handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-7s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
logging.basicConfig(level=logging.DEBUG, handlers=[_handler], force=True)
_lvl_env = (os.environ.get("LOG_LEVEL") or os.environ.get("S2S_LOG_LEVEL") or "INFO").strip().upper()
_is_debug = _lvl_env in {"DEBUG", "TRACE", "10"}
# Keep noisy loggers quiet unless LOG_LEVEL explicitly enables debug.
logging.getLogger("httpx").setLevel(logging.DEBUG if _is_debug else logging.WARNING)
logging.getLogger("websockets").setLevel(logging.DEBUG if _is_debug else logging.INFO)
logging.getLogger("uvicorn").setLevel(logging.DEBUG if _is_debug else logging.INFO)
logging.getLogger("uvicorn.access").setLevel(logging.DEBUG if _is_debug else logging.INFO)
logging.getLogger("httpcore").setLevel(logging.DEBUG if _is_debug else logging.WARNING)
logger.setLevel(logging.DEBUG if _is_debug else logging.DEBUG) # s2s always uses at least DEBUG in development
_logging_configured = True
logger.debug(
"LOG_LEVEL env=%s — noisy-logger levels: httpx=%s uvicorn=%s websockets=%s",
_lvl_env,
logging.getLevelName(logging.getLogger("httpx").level),
logging.getLevelName(logging.getLogger("uvicorn").level),
logging.getLevelName(logging.getLogger("websockets").level),
)
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from fastapi import Request, WebSocket
from s2s_client.core import config as settings
def _url_with_query(url: str, **params: str) -> str:
target = urlsplit(url)
merged_query = dict(parse_qsl(target.query, keep_blank_values=True))
for key, value in params.items():
if value:
merged_query[key] = value
return urlunsplit(
(
target.scheme,
target.netloc,
target.path,
urlencode(merged_query, doseq=True),
target.fragment,
)
)
def _proxy_public_url(request: Request) -> str:
if not settings.S2S_PROXY_TARGET:
return ""
forwarded_proto = (request.headers.get("x-forwarded-proto") or request.url.scheme).split(",")[0].strip()
forwarded_host = (
(request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc)
.split(",")[0]
.strip()
)
ws_scheme = "wss" if forwarded_proto in {"https", "wss"} else "ws"
root_path = (request.scope.get("root_path") or "").rstrip("/")
return f"{ws_scheme}://{forwarded_host}{root_path}/v1/realtime"
def _proxy_upstream_url(ws: WebSocket) -> str:
target = urlsplit(settings.S2S_PROXY_TARGET)
merged_query = dict(parse_qsl(target.query, keep_blank_values=True))
merged_query.update(parse_qsl(ws.url.query, keep_blank_values=True))
return urlunsplit(
(
target.scheme,
target.netloc,
target.path or "/v1/realtime",
urlencode(merged_query, doseq=True),
target.fragment,
)
)