from __future__ import annotations import os from dotenv import load_dotenv from vocero_api.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" 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= # and expects JSON back (optionally wrapped as {"result": }). 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 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 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 /tools with include_unauthorized=true (guide §2.1) # to discover tools, and POST /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: # 2) Authorization: Bearer # 3) x-demo-session: # 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 # Speech-to-speech load balancer URL. When set, the browser POSTs /api/session # (which proxies /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):///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 # Optional authenticated AquaPortal MCP integration. Legacy deployments remain unchanged. S2S_MCP_URL = os.environ.get("S2S_MCP_URL", "").strip() def _bounded_mcp_int(name: str, default: int, minimum: int, maximum: int) -> int: try: value = int(os.environ.get(name, str(default))) except ValueError: value = default return max(minimum, min(maximum, value)) S2S_MCP_TIMEOUT_SEC = _bounded_mcp_int("S2S_MCP_TIMEOUT_SEC", 20, 1, 120) S2S_MCP_MAX_CALLS_PER_TURN = _bounded_mcp_int("S2S_MCP_MAX_CALLS_PER_TURN", 6, 1, 20) S2S_CONVERSATION_TTL_SEC = _bounded_mcp_int("S2S_CONVERSATION_TTL_SEC", 3600, 60, 86400) S2S_MAX_CONVERSATIONS = _bounded_mcp_int("S2S_MAX_CONVERSATIONS", 1000, 1, 10000)