diff --git a/docs/telnyx_media_streaming.md b/docs/telnyx_media_streaming.md new file mode 100644 index 00000000..65a13880 --- /dev/null +++ b/docs/telnyx_media_streaming.md @@ -0,0 +1,140 @@ +# Telnyx Media Streaming transport (auto-provisioned) + +An alternative to the direct anonymous-WebRTC path for `EVA_FRAMEWORK=telnyx`. Instead of +opening a browser-style WebRTC session (ICE/DTLS/SRTP), EVA places a **Call Control** +outbound call to the assistant's SIP address and bridges audio over a **Media Streaming +WebSocket** (bidirectional PCMU). No WebRTC, so none of the ICE/gateway interop problems of +the WebRTC path apply. + +**You provide only a Telnyx API key.** Everything else — the connection, an idle phone +number for caller ID, the outbound voice profile — is discovered or created for you. + +> **Verified end to end.** A live EVA CLI run over this transport graded 1/1 (100%): the +> assistant heard the caller (2 user turns, real audio), and every metric — including the +> `user_speech_fidelity` audio judge — scored. See VSDK-277. + +## Turnkey: `webhook_base_url: "auto"` (recommended) + +Because EVA is one-shot, the transport can bring up its own **Cloudflare quick tunnel** for +the duration of the run — no account, no manual tunnel, no public URL to arrange. Set +`"webhook_base_url": "auto"` (or `"auto_tunnel": true`) in `s2s_params`; on start, EVA +launches `cloudflared` against its media port, uses the assigned `https://…trycloudflare.com` +URL, points the connection's webhook at it, and tears the tunnel down on exit. Requires the +`cloudflared` binary on PATH. Then it is just: + +```bash +export TELNYX_API_KEY=KEY... OPENAI_API_KEY=sk-... GEMINI_API_KEY=AQ... +python -m eva.assistant.telnyx_provisioning --assistant-id assistant-473093df-... \ + --public-url https://placeholder --provision # once: fills connection_id/from_number +uv run python main.py +``` + +with `EVA_MODEL__S2S_PARAMS='{"transport":"media_streaming","model":"…","assistant_id":"…","api_key":"KEY…","connection_id":"…","from_number":"…","webhook_base_url":"auto"}'`. + +`GEMINI_API_KEY` is used by the audio judge (`user_speech_fidelity`), which calls Gemini +directly (not the LiteLLM router). `OPENAI_API_KEY` drives the Realtime caller + text judges. + +The rest of this doc covers the manual-URL path (explicit tunnel or in-cluster ingress), +useful for a hosted/persistent deployment. + +## 1. Provision (needs only the API key) + +```bash +export TELNYX_API_KEY=KEY... + +# read-only: shows what exists and what is missing, creates nothing +python -m eva.assistant.telnyx_provisioning \ + --assistant-id assistant-473093df-... \ + --public-url https:// + +# create/attach whatever is missing (reuse-first; buys nothing) +python -m eva.assistant.telnyx_provisioning \ + --assistant-id assistant-473093df-... \ + --public-url https:// \ + --provision +``` + +It is **idempotent and reuse-first**: it looks for a Call Control Application named +`eva-media-streaming`, an existing outbound voice profile, and an **unattached** active +number (so it never disturbs the routing of a number already in use). Re-running is a no-op; +if your public URL changed (tunnels do), it re-points the connection's webhook. + +It prints the `EVA_MODEL__S2S_PARAMS` to drop into `.env`: + +```bash +EVA_FRAMEWORK=telnyx +EVA_MODEL__S2S=telnyx +EVA_MODEL__S2S_PARAMS='{"transport":"media_streaming","assistant_id":"assistant-473093df-...","api_key":"KEY...","connection_id":"3004642678097839809","from_number":"+13318719043","to":"sip:anonymous@assistant-473093df-...sip.telnyx.com","webhook_base_url":"https://"}' +``` + +`telnyx_server` selects the media-streaming transport automatically whenever +`connection_id` and `webhook_base_url` are present in `s2s_params` (`_use_call_control`). + +## 2. What "public ingress" means, and why you need it + +The WebRTC path is **outbound-only**: EVA dials out and everything rides that one connection, +so EVA can sit behind NAT with no inbound reachability. **Media Streaming is different — it +requires Telnyx to connect back *to you*.** Two inbound flows: + +1. **Webhooks** — after you start a call, Telnyx sends HTTP POSTs (`call.answered`, + `streaming.started`, …) to `POST {webhook_base_url}/call-control-events`. +2. **The media WebSocket** — Telnyx opens a WebSocket *to your server* at + `wss://{webhook_base_url}/media-stream/{conversation_id}` and streams the call audio over + it, in both directions. + +So Telnyx's servers, out on the public internet, must be able to **reach your EVA process**. +"Public ingress" is exactly that: a publicly resolvable HTTPS/WSS URL that routes inbound +connections to the local port EVA is listening on (default `:PORT` from the run config). + +Your EVA process normally listens on `localhost` (or a private pod IP) that the public +internet cannot reach. You bridge that gap one of two ways: + +- **A tunnel (easiest for a laptop or a locked-down pod).** A tool that opens an outbound + connection to a public relay and gives you back a public URL that forwards to your local + port: + ```bash + # cloudflared (no account needed for a quick tunnel) + cloudflared tunnel --url http://localhost:8080 + # -> https://random-words.trycloudflare.com <- use this as --public-url + + # or ngrok + ngrok http 8080 + # -> https://xxxx.ngrok-free.app + ``` + The tunnel must forward **both** HTTP (webhooks) and WebSocket (media) — cloudflared and + ngrok both do. Note the URL changes each run unless you have a reserved/named tunnel; just + re-run the provisioner to re-point the webhook. + +- **A real ingress (for a deployment).** Expose the EVA service through a Kubernetes + Ingress / load balancer with a public IP and a DNS name and TLS — e.g. a `Service` of + type `LoadBalancer`, or an Ingress resource. Then `--public-url` is that stable hostname. + +Whichever you pick, `webhook_base_url` in `s2s_params` must be that public URL, and it must +terminate TLS (Telnyx requires `https`/`wss`). The provisioner stores it on the connection's +webhook and it is used to build the media-stream URL handed to Telnyx. + +## 3. Run + +```bash +# EVA listens locally; the tunnel/ingress makes it reachable; the call streams audio back. +cloudflared tunnel --url http://localhost:8080 # -> copy the https URL +python -m eva.assistant.telnyx_provisioning --assistant-id ... --public-url --provision +uv run python main.py +``` + +The call flow: EVA `POST /v2/calls` (connection_id, from, to=assistant SIP, `stream_url` = +your `wss://…/media-stream/…`) → Telnyx dials the assistant → `streaming.started` webhook → +Telnyx opens the media WebSocket to you → audio bridges over it, both directions. + +## 4. Why this exists alongside the WebRTC path + +| | WebRTC (`TelnyxWebRTCClient`) | Media Streaming (`TelnyxCallControlTransport`) | +|---|---|---| +| Credentials | public `assistant_id` only | API key (auto-provisions the rest) | +| Inbound reachability | **none needed** (outbound-only) | **public ingress required** | +| Media stack | ICE / DTLS / SRTP (aiortc) | plain WebSocket, PCMU — no ICE | +| Status | blocked on a b2bua-rtc ICE/media interop bug (VSDK-277) | not subject to that bug | + +Use WebRTC when you can only supply an assistant id and can't expose a public URL (e.g. an +upstream EVA maintainer). Use Media Streaming when you have an API key and can provide public +ingress, and want a transport that sidesteps the WebRTC media path entirely. diff --git a/pyproject.toml b/pyproject.toml index f7d79a95..6b2af8c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,9 @@ dependencies = [ "uvicorn>=0.23.0", "websockets>=12.0", "httpx>=0.25.0", + "aiohttp>=3.8.0", + "aiortc>=1.6.0", + "soxr>=0.3.0", "pyyaml>=6.0", "pandas>=2.0", "numpy>=1.24", diff --git a/src/eva/assistant/telnyx_provisioning/__init__.py b/src/eva/assistant/telnyx_provisioning/__init__.py new file mode 100644 index 00000000..2c648829 --- /dev/null +++ b/src/eva/assistant/telnyx_provisioning/__init__.py @@ -0,0 +1,18 @@ +"""Telnyx Media Streaming provisioning. + +Given only a Telnyx API key, discover or create every account resource the Call Control +media-streaming transport needs to place a call to an AI Assistant: + + * a Call Control Application (the "connection" — carries the webhook URL and the + outbound-calling settings), + * an owned phone number to use as the caller ID (`from`), attached to that connection, + * an outbound voice profile (so the connection is allowed to place calls). + +Reuse-first and idempotent: nothing is created if a suitable resource already exists, and +re-running is a no-op. Read-only discovery needs no confirmation; creation is gated behind +`provision(create=True)`. +""" + +from .provision import ProvisionResult, TelnyxProvisioner + +__all__ = ["TelnyxProvisioner", "ProvisionResult"] diff --git a/src/eva/assistant/telnyx_provisioning/__main__.py b/src/eva/assistant/telnyx_provisioning/__main__.py new file mode 100644 index 00000000..710b7517 --- /dev/null +++ b/src/eva/assistant/telnyx_provisioning/__main__.py @@ -0,0 +1,4 @@ +from eva.assistant.telnyx_provisioning.provision import main + +if __name__ == "__main__": + main() diff --git a/src/eva/assistant/telnyx_provisioning/provision.py b/src/eva/assistant/telnyx_provisioning/provision.py new file mode 100644 index 00000000..2c40cc37 --- /dev/null +++ b/src/eva/assistant/telnyx_provisioning/provision.py @@ -0,0 +1,255 @@ +"""Idempotent, reuse-first Telnyx account provisioning for the media-streaming transport. + +Usage (read-only discovery — safe, no changes, no confirmation needed): + + python -m eva.assistant.telnyx_provisioning \ + --assistant-id assistant-473093df-... \ + --public-url https://your-tunnel.ngrok-free.app + +Usage (create/attach missing resources — reuses everything it can, buys nothing): + + python -m eva.assistant.telnyx_provisioning \ + --assistant-id assistant-473093df-... \ + --public-url https://your-tunnel.ngrok-free.app \ + --provision + +The API key is read from --api-key or TELNYX_API_KEY. On success it prints the +EVA_MODEL__S2S_PARAMS JSON to drop into your .env. +""" + +from __future__ import annotations + +import argparse +import json +import os +from dataclasses import asdict, dataclass +from typing import Any, Self + +import httpx + +TELNYX_API = "https://api.telnyx.com/v2" + +# Resources we own are tagged/named with this so re-runs find and reuse them. +MANAGED_APP_NAME = "eva-media-streaming" +MANAGED_PROFILE_NAME = "eva-media-streaming" + + +@dataclass +class ProvisionResult: + connection_id: str + from_number: str + to: str + webhook_base_url: str + outbound_voice_profile_id: str | None = None + created: list[str] | None = None + + def s2s_params(self, assistant_id: str, api_key: str) -> dict[str, Any]: + """The EVA_MODEL__S2S_PARAMS payload for the media-streaming transport.""" + return { + "transport": "media_streaming", + "assistant_id": assistant_id, + "api_key": api_key, + "connection_id": self.connection_id, + "from_number": self.from_number, + "to": self.to, + "webhook_base_url": self.webhook_base_url, + } + + +class TelnyxProvisioner: + def __init__(self, api_key: str, *, timeout: float = 30.0) -> None: + if not api_key: + raise ValueError("A Telnyx API key is required (TELNYX_API_KEY or --api-key).") + self._client = httpx.Client( + base_url=TELNYX_API, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=timeout, + ) + self._api_key = api_key + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + # --- low-level helpers --------------------------------------------------- + + def _get(self, path: str, **params: Any) -> list[dict[str, Any]]: + r = self._client.get(path, params=params) + r.raise_for_status() + return r.json().get("data", []) + + def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + r = self._client.post(path, json=payload) + if r.status_code >= 400: + raise RuntimeError(f"POST {path} -> {r.status_code}: {r.text[:400]}") + return r.json().get("data", {}) + + def _patch(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + r = self._client.patch(path, json=payload) + if r.status_code >= 400: + raise RuntimeError(f"PATCH {path} -> {r.status_code}: {r.text[:400]}") + return r.json().get("data", {}) + + # --- discovery ----------------------------------------------------------- + + def find_connection(self) -> dict[str, Any] | None: + for app in self._get("/call_control_applications", **{"page[size]": 250}): + if app.get("application_name") == MANAGED_APP_NAME: + return app + return None + + def find_outbound_profile(self) -> dict[str, Any] | None: + for prof in self._get("/outbound_voice_profiles", **{"page[size]": 250}): + if prof.get("name") == MANAGED_PROFILE_NAME: + return prof + # fall back to any existing profile so we don't create one unnecessarily + existing = self._get("/outbound_voice_profiles", **{"page[size]": 1}) + return existing[0] if existing else None + + def list_owned_numbers(self) -> list[dict[str, Any]]: + return self._get("/phone_numbers", **{"page[size]": 250, "filter[status]": "active"}) + + def pick_from_number(self, preferred: str | None = None) -> dict[str, Any] | None: + numbers = self.list_owned_numbers() + if preferred: + for n in numbers: + if n.get("phone_number") == preferred: + return n + raise ValueError(f"Requested from-number {preferred} is not active on this account.") + # Prefer a number NOT already attached to a connection, so we never disrupt the + # routing of a number that is in use elsewhere. Among those, prefer US/NANP. + unattached = [n for n in numbers if not n.get("connection_id")] + pool = unattached or numbers + for n in pool: + if str(n.get("phone_number", "")).startswith("+1"): + return n + return pool[0] if pool else None + + # --- provisioning -------------------------------------------------------- + + def ensure( + self, + *, + assistant_id: str, + public_url: str, + from_number: str | None = None, + create: bool = False, + ) -> ProvisionResult: + created: list[str] = [] + webhook_base = public_url.rstrip("/") + + # 1) outbound voice profile (needed for the connection to place calls) + profile = self.find_outbound_profile() + if profile is None: + if not create: + raise _NeedsCreate("outbound_voice_profile", "no outbound voice profile exists") + profile = self._post( + "/outbound_voice_profiles", + {"name": MANAGED_PROFILE_NAME, "traffic_type": "conversational"}, + ) + created.append(f"outbound_voice_profile:{profile['id']}") + profile_id = profile["id"] + + # 2) Call Control Application (the connection), with our webhook URL + conn = self.find_connection() + webhook_event_url = f"{webhook_base}/call-control-events" + if conn is None: + if not create: + raise _NeedsCreate( + "call_control_application", + f"no connection named {MANAGED_APP_NAME!r}", + ) + conn = self._post( + "/call_control_applications", + { + "application_name": MANAGED_APP_NAME, + "webhook_event_url": webhook_event_url, + "outbound": {"outbound_voice_profile_id": profile_id}, + "active": True, + }, + ) + created.append(f"call_control_application:{conn['id']}") + elif conn.get("webhook_event_url") != webhook_event_url: + # keep the webhook pointed at the current public URL (tunnels change) + if create: + conn = self._patch( + f"/call_control_applications/{conn['id']}", + {"webhook_event_url": webhook_event_url}, + ) + created.append(f"call_control_application:{conn['id']}:webhook_updated") + connection_id = conn["id"] + + # 3) a from-number attached to this connection + number = self.pick_from_number(from_number) + if number is None: + raise RuntimeError( + "No active phone number on the account to use as caller ID. " + "Order one in the portal (Numbers -> Buy Numbers) or pass --from-number." + ) + num_id = number["id"] + if number.get("connection_id") != connection_id: + if not create: + raise _NeedsCreate( + "phone_number_assignment", + f"{number['phone_number']} is not attached to the connection", + ) + self._patch(f"/phone_numbers/{num_id}", {"connection_id": connection_id}) + created.append(f"phone_number:{number['phone_number']}:attached") + + # the AI Assistant SIP destination — reachable with just the public assistant id + to = f"sip:anonymous@{assistant_id}.sip.telnyx.com" + + return ProvisionResult( + connection_id=connection_id, + from_number=number["phone_number"], + to=to, + webhook_base_url=webhook_base, + outbound_voice_profile_id=profile_id, + created=created or None, + ) + + +class _NeedsCreate(RuntimeError): + def __init__(self, resource: str, why: str) -> None: + super().__init__(f"missing {resource}: {why} (re-run with --provision to create it)") + self.resource = resource + + +def main() -> None: + ap = argparse.ArgumentParser(description="Provision Telnyx resources for EVA media streaming.") + ap.add_argument("--assistant-id", required=True) + ap.add_argument("--public-url", required=True, help="Public base URL Telnyx can reach (tunnel or ingress).") + ap.add_argument("--api-key", default=os.environ.get("TELNYX_API_KEY")) + ap.add_argument("--from-number", default=None, help="E.164 number to use as caller ID (default: pick one).") + ap.add_argument("--provision", action="store_true", help="Create/attach missing resources (else inspect only).") + args = ap.parse_args() + + with TelnyxProvisioner(args.api_key) as prov: + try: + result = prov.ensure( + assistant_id=args.assistant_id, + public_url=args.public_url, + from_number=args.from_number, + create=args.provision, + ) + except _NeedsCreate as exc: + print(f"[inspect] {exc}") + raise SystemExit(2) from exc + + print("\n=== provisioned ===") + for k, v in asdict(result).items(): + print(f" {k}: {v}") + print("\n=== drop into .env ===") + print("EVA_FRAMEWORK=telnyx") + print("EVA_MODEL__S2S=telnyx") + params = result.s2s_params(args.assistant_id, "") + print(f"EVA_MODEL__S2S_PARAMS='{json.dumps(params)}'") + + +if __name__ == "__main__": + main() diff --git a/src/eva/assistant/telnyx_provisioning/tunnel.py b/src/eva/assistant/telnyx_provisioning/tunnel.py new file mode 100644 index 00000000..ddc27009 --- /dev/null +++ b/src/eva/assistant/telnyx_provisioning/tunnel.py @@ -0,0 +1,78 @@ +"""Ephemeral public URL via a Cloudflare quick tunnel — for one-shot media-streaming runs. + +EVA is a one-shot CLI, so instead of asking the user to run a tunnel and paste a URL, the +media-streaming transport can bring up a Cloudflare quick tunnel itself for the duration of +the run: it publishes a public ``https://.trycloudflare.com`` that forwards to EVA's +local media port, then tears it down on exit. Quick tunnels need **no Cloudflare account**. + +Requires the ``cloudflared`` binary on PATH (https://github.com/cloudflare/cloudflared). +""" + +from __future__ import annotations + +import asyncio +import re +import shutil +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from eva.utils.logging import get_logger + +logger = get_logger(__name__) + +_URL_RE = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com") + + +class CloudflaredNotFound(RuntimeError): + pass + + +async def _read_url(proc: asyncio.subprocess.Process, timeout: float) -> str: + """Cloudflared prints the assigned URL to stderr shortly after start.""" + assert proc.stderr is not None + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + try: + line = await asyncio.wait_for(proc.stderr.readline(), timeout=deadline - asyncio.get_event_loop().time()) + except TimeoutError: + break + if not line: + break + m = _URL_RE.search(line.decode("utf-8", "replace")) + if m: + return m.group(0) + raise TimeoutError("cloudflared did not report a tunnel URL in time") + + +@asynccontextmanager +async def cloudflare_quick_tunnel(local_port: int, *, url_timeout: float = 30.0) -> AsyncIterator[str]: + """Start a Cloudflare quick tunnel to ``http://localhost:{local_port}``; yield its public URL. + + The tunnel process is terminated when the context exits. + """ + binary = shutil.which("cloudflared") + if not binary: + raise CloudflaredNotFound( + "cloudflared not found on PATH. Install it " + "(https://github.com/cloudflare/cloudflared) or set webhook_base_url explicitly." + ) + proc = await asyncio.create_subprocess_exec( + binary, + "tunnel", + "--no-autoupdate", + "--url", + f"http://localhost:{local_port}", + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + try: + url = await _read_url(proc, url_timeout) + logger.info("Cloudflare quick tunnel up: %s -> http://localhost:%s", url, local_port) + yield url + finally: + if proc.returncode is None: + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=5) + except TimeoutError: + proc.kill() diff --git a/src/eva/assistant/telnyx_server.py b/src/eva/assistant/telnyx_server.py new file mode 100644 index 00000000..ac7228ac --- /dev/null +++ b/src/eva/assistant/telnyx_server.py @@ -0,0 +1,1981 @@ +"""Telnyx hosted AI Assistant server for EVA. + +Expected ``pipeline_config.s2s_params`` keys: + +Required for direct Telnyx WebRTC calls: + * ``assistant_id`` or ``assistant_agent_id``: Telnyx AI Assistant target id. + +Optional for direct Telnyx WebRTC calls: + * ``target_version_id``: specific Telnyx AI Assistant version. + * ``host`` or ``websocket_host``: Verto websocket host, defaults to + ``wss://rtc.telnyx.com``. + * ``target_params`` and ``user_variables``: anonymous-login metadata. + * ``caller_name``, ``caller_number``, ``client_state``, ``custom_headers``: + dialog metadata sent with the WebRTC invite. + +Optional assistant setup: + * ``create_assistant``: create a Telnyx AI Assistant before the run. + * ``api_key`` or ``telnyx_api_key``: Telnyx API key, required only for REST + assistant creation/deletion or transcript fetch helpers. + * ``api_base``: defaults to ``https://api.telnyx.com``; ``/v2`` is appended + automatically for v2 endpoints unless already present. + * ``model``, ``voice``, and ``stt_model``: used when creating an assistant. + +The server exposes the upstream EVA assistant-server contract to the local user +simulator while bridging to Telnyx's anonymous Verto/WebRTC AI Assistant path. +""" + +from __future__ import annotations + +import asyncio +import audioop +import base64 +import json +import time +import uuid +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from datetime import datetime +from fractions import Fraction +from pathlib import Path +from typing import Any + +import aiohttp +import uvicorn +from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect + +from eva.assistant.audio_bridge import ( + FrameworkLogWriter, + MetricsLogWriter, + create_twilio_media_message, + mulaw_8k_to_pcm16_16k, + mulaw_8k_to_pcm16_24k, + parse_twilio_media_message, + sync_buffer_to_position, +) +from eva.assistant.base_server import AbstractAssistantServer +from eva.models.agents import AgentConfig, AgentTool +from eva.utils.conversation_checks import resolve_user_simulator_events_path +from eva.utils.culture import get_initial_message +from eva.utils.logging import get_logger + +logger = get_logger(__name__) + +TELNYX_DEFAULT_API_BASE = "https://api.telnyx.com" +TELNYX_PROD_RTC_HOST = "wss://rtc.telnyx.com" +TELNYX_DEV_RTC_HOST = "wss://rtcdev.telnyx.com" +TELNYX_SAMPLE_RATE = 16000 +RECORDING_SAMPLE_RATE = 24000 +PCM_SAMPLE_WIDTH = 2 +MULAW_CHUNK_SIZE = 160 +MULAW_CHUNK_DURATION_S = 0.02 +# Telnyx's inter-turn frames decode to digital zero; anything at or below this RMS is +# silence and must not be forwarded, or the user simulator never gets a turn. +ASSISTANT_SILENCE_RMS = 1 +TELNYX_USER_AGENT = "EVA-TelnyxWebRTC/0.1" +# Mirrors DEFAULT_PROD_ICE_SERVERS from @telnyx/webrtc 2.27.4 +# (STUN + TURN UDP/3478 + TURN TCP/3478 + TURNS on 443). The TURNS/443 entry +# is the last-resort TLS fallback for networks that block both 3478 transports. +TELNYX_DEFAULT_ICE_SERVERS = [ + {"urls": "stun:stun.telnyx.com:3478"}, + {"urls": "stun:stun.l.google.com:19302"}, + { + "urls": "turn:turn.telnyx.com:3478?transport=udp", + "username": "testuser", + "credential": "testpassword", + }, + { + "urls": "turn:turn.telnyx.com:3478?transport=tcp", + "username": "testuser", + "credential": "testpassword", + }, + { + "urls": "turns:turn2.telnyx.com:443", + "username": "testuser", + "credential": "testpassword", + }, +] + + +def _wall_ms() -> str: + return str(int(round(time.time() * 1000))) + + +def _normalize_api_v2_base(api_base: str | None) -> str: + base = (api_base or TELNYX_DEFAULT_API_BASE).rstrip("/") + if base.endswith("/v2"): + return base + return f"{base}/v2" + + +def _pcm16_16k_to_pcm16_24k(pcm_16k: bytes) -> bytes: + if not pcm_16k: + return b"" + pcm_24k, _ = audioop.ratecv(pcm_16k, PCM_SAMPLE_WIDTH, 1, 16000, 24000, None) + return pcm_24k + + +def _pcm16_16k_to_mulaw_8k(pcm_16k: bytes) -> bytes: + if not pcm_16k: + return b"" + pcm_8k, _ = audioop.ratecv(pcm_16k, PCM_SAMPLE_WIDTH, 1, 16000, 8000, None) + return audioop.lin2ulaw(pcm_8k, PCM_SAMPLE_WIDTH) + + +def _iso8601_to_epoch_ms(value: str) -> int: + dt = datetime.fromisoformat(value) + return int(dt.timestamp() * 1000) + + +def _is_epoch_ms(value: str | None) -> bool: + if not value: + return False + try: + return int(value) > 1_000_000_000_000 + except (TypeError, ValueError): + return False + + +def _extract_tool_params(body: Any) -> dict[str, Any]: + """Normalize Telnyx webhook bodies to the function argument dict.""" + if body is None: + return {} + if not isinstance(body, dict): + raise ValueError("Tool webhook body must be a JSON object") + + function_params = body.get("function_params") + if function_params is None: + return body + if not isinstance(function_params, dict): + raise ValueError("function_params must be a JSON object") + return function_params + + +def _message_text(message: dict[str, Any]) -> str: + text = message.get("text") + if isinstance(text, str): + return text.strip() + + content = message.get("content") + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + part_text = item.get("text") or item.get("content") + if isinstance(part_text, str): + parts.append(part_text) + return "".join(parts).strip() + return "" + + +def _conversation_item_text(item: dict[str, Any]) -> str: + content = item.get("content") + if isinstance(content, str): + return content.strip() + if not isinstance(content, list): + return "" + parts: list[str] = [] + for part in content: + if isinstance(part, str): + parts.append(part) + elif isinstance(part, dict): + text = part.get("text") or part.get("content") + if isinstance(text, str): + parts.append(text) + return "".join(parts).strip() + + +def parse_telnyx_intended_speech_payload(payload: dict[str, Any]) -> list[dict[str, Any]]: + """Extract chronological assistant messages from Telnyx conversation messages.""" + data = payload.get("data", []) + if not isinstance(data, list): + return [] + + intended_speech: list[dict[str, Any]] = [] + for message in reversed(data): + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + text = _message_text(message) + if not text: + continue + sent_at = message.get("sent_at") or message.get("created_at") + if not isinstance(sent_at, str) or not sent_at.strip(): + continue + try: + timestamp_ms = _iso8601_to_epoch_ms(sent_at) + except ValueError: + continue + intended_speech.append({"text": text, "timestamp_ms": timestamp_ms}) + return intended_speech + + +def extract_user_speech_events(events_path: Path) -> list[dict[str, Any]]: + """Read simulated-user transcript events saved by ``UserSimulator``.""" + if not events_path.exists(): + return [] + + turns: list[dict[str, Any]] = [] + with open(events_path, encoding="utf-8") as file_obj: + for line in file_obj: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("type") != "user_speech": + continue + data = event.get("data", {}) + if not isinstance(data, dict): + continue + text = str(data.get("text", "")).strip() + if text: + turns.append({"text": text, "timestamp_ms": str(event.get("timestamp", ""))}) + return turns + + +def extract_assistant_speech_events(events_path: Path) -> list[dict[str, Any]]: + """Read assistant speech heard by the user simulator as a fallback transcript.""" + if not events_path.exists(): + return [] + + turns: list[dict[str, Any]] = [] + with open(events_path, encoding="utf-8") as file_obj: + for line in file_obj: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("type") != "assistant_speech": + continue + data = event.get("data", {}) + if not isinstance(data, dict): + continue + text = str(data.get("text", "")).strip() + if text: + turns.append({"text": text, "timestamp_ms": int(event.get("timestamp", 0))}) + return turns + + +class TelnyxAssistantManager: + """Create and manage Telnyx AI assistants for benchmark runs.""" + + DEFAULT_MODEL = "moonshotai/Kimi-K2.5" + DEFAULT_VOICE = "Telnyx.Ultra.a7a59115-2425-4192-844c-1e98ec7d6877" + DEFAULT_STT_MODEL = "deepgram/flux" + + def __init__( + self, + api_key: str, + api_base: str = TELNYX_DEFAULT_API_BASE, + session: aiohttp.ClientSession | None = None, + ): + self.api_key = api_key + self.api_base = _normalize_api_v2_base(api_base) + self._session_owner = session is None + self.session = session or aiohttp.ClientSession( + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + timeout=aiohttp.ClientTimeout(total=30.0), + ) + + async def create_benchmark_assistant( + self, + agent_config: AgentConfig, + agent_config_path: str, + webhook_base_url: str | None = None, + model: str | None = None, + voice: str | None = None, + stt_model: str | None = None, + ) -> str: + payload = self._build_assistant_payload( + agent_config=agent_config, + agent_config_path=agent_config_path, + webhook_base_url=webhook_base_url, + model=model or self.DEFAULT_MODEL, + voice=voice or self.DEFAULT_VOICE, + stt_model=stt_model or self.DEFAULT_STT_MODEL, + ) + async with self.session.post(f"{self.api_base}/ai/assistants", json=payload) as response: + response_payload = await self._parse_response_json(response) + if response.status >= 400: + raise RuntimeError( + f"Failed to create Telnyx assistant: {response.status} " + f"{json.dumps(response_payload, sort_keys=True)}" + ) + + assistant_id = str(response_payload.get("id") or response_payload.get("data", {}).get("id") or "").strip() + if not assistant_id: + raise RuntimeError(f"Telnyx assistant creation response did not include an id: {response_payload}") + return assistant_id + + async def delete_assistant(self, assistant_id: str) -> None: + async with self.session.delete(f"{self.api_base}/ai/assistants/{assistant_id}") as response: + payload = await self._parse_response_json(response) + if response.status >= 400: + raise RuntimeError( + f"Failed to delete Telnyx assistant {assistant_id}: " + f"{response.status} {json.dumps(payload, sort_keys=True)}" + ) + + async def get_assistant_model(self, assistant_id: str) -> str: + async with self.session.get(f"{self.api_base}/ai/assistants/{assistant_id}") as response: + payload = await self._parse_response_json(response) + if response.status >= 400: + raise RuntimeError( + f"Failed to get Telnyx assistant {assistant_id}: " + f"{response.status} {json.dumps(payload, sort_keys=True)}" + ) + return str(payload.get("model") or payload.get("data", {}).get("model", "")) + + async def update_assistant_model(self, assistant_id: str, model: str) -> None: + async with self.session.patch( + f"{self.api_base}/ai/assistants/{assistant_id}", json={"model": model} + ) as response: + payload = await self._parse_response_json(response) + if response.status >= 400: + raise RuntimeError( + f"Failed to update Telnyx assistant model: {response.status} {json.dumps(payload, sort_keys=True)}" + ) + + async def close(self) -> None: + if self._session_owner and not self.session.closed: + await self.session.close() + + @staticmethod + async def _parse_response_json(response: aiohttp.ClientResponse) -> dict[str, Any]: + try: + payload = await response.json() + except aiohttp.ContentTypeError: + payload = {"message": await response.text()} + return payload if isinstance(payload, dict) else {"data": payload} + + def _build_assistant_payload( + self, + agent_config: AgentConfig, + agent_config_path: str, + webhook_base_url: str | None, + model: str, + voice: str, + stt_model: str | None = None, + ) -> dict[str, Any]: + normalized_webhook_base = webhook_base_url.rstrip("/") if webhook_base_url else "" + tools = ( + [self._build_webhook_tool(tool, normalized_webhook_base) for tool in agent_config.tools] + if normalized_webhook_base + else [] + ) + if not any(tool.id == "end_call" for tool in agent_config.tools): + tools.append( + { + "type": "hangup", + "hangup": { + "description": ( + "To be used whenever the conversation has ended " + "and it would be appropriate to hang up the call." + ), + }, + } + ) + + payload = { + "name": f"EVA Benchmark - {agent_config.name}", + "description": ( + f"Auto-generated by EVA from {Path(agent_config_path).name} for benchmark agent {agent_config.id}." + ), + "instructions": self._build_system_prompt(agent_config), + "model": model, + "greeting": getattr(agent_config, "greeting", None) or get_initial_message("en"), + "tools": tools, + "enabled_features": ["telephony"], + "voice_settings": { + "voice": voice, + "voice_speed": 1.2, + "expressive_mode": False, + "language_boost": "English", + "similarity_boost": 0.5, + "style": 0.0, + "use_speaker_boost": True, + "background_audio": { + "type": "predefined_media", + "value": "silence", + "volume": 0.5, + }, + }, + "transcription": { + "model": stt_model or self.DEFAULT_STT_MODEL, + "language": "en", + "settings": { + "eot_threshold": 0.9, + "eot_timeout_ms": 5000, + "eager_eot_threshold": 0.9, + }, + }, + "telephony_settings": { + "supports_unauthenticated_web_calls": True, + "time_limit_secs": 600, + "user_idle_timeout_secs": 60, + }, + "interruption_settings": { + "enable": True, + "start_speaking_plan": { + "wait_seconds": 0.1, + "transcription_endpointing_plan": { + "on_punctuation_seconds": 0.1, + "on_no_punctuation_seconds": 0.1, + "on_number_seconds": 0.1, + }, + }, + }, + "privacy_settings": { + "data_retention": True, + "pii_redaction": "disabled", + }, + "dynamic_variables": { + "eva_call_id": None, + }, + } + if normalized_webhook_base: + payload["dynamic_variables_webhook_url"] = f"{normalized_webhook_base}/dynamic-variables" + return payload + + @staticmethod + def _build_system_prompt(agent_config: AgentConfig) -> str: + sections = [ + agent_config.description.strip(), + f"Role: {agent_config.role.strip()}", + agent_config.personality.strip() if agent_config.personality else "", + agent_config.instructions.strip(), + ] + return "\n\n".join(section for section in sections if section) + + @staticmethod + def _build_webhook_tool(tool: AgentTool, webhook_base_url: str) -> dict[str, Any]: + return { + "type": "webhook", + "timeout_ms": 5000, + "webhook": { + "name": tool.id, + "description": tool.description, + "url": f"{webhook_base_url}/tools/{{{{eva_call_id}}}}/{tool.id}", + "method": "POST", + "path_parameters": {"type": "object", "properties": {}}, + "query_parameters": {"type": "object", "properties": {}}, + "body_parameters": { + "type": "object", + "properties": tool.get_parameter_properties(), + "required": tool.get_required_param_names(), + }, + "headers": [], + }, + } + + +@dataclass +class TelnyxDirectSessionConfig: + """Configuration for Telnyx anonymous Verto/WebRTC AI Assistant sessions.""" + + assistant_id: str + host: str = TELNYX_PROD_RTC_HOST + target_version_id: str | None = None + target_params: dict[str, Any] = field(default_factory=dict) + user_variables: dict[str, Any] = field(default_factory=dict) + caller_name: str = "EVA User" + caller_number: str = "+15555550100" + destination_number: str = "ai_assistant" + client_state: str | None = None + custom_headers: list[dict[str, str]] = field(default_factory=list) + ice_servers: list[dict[str, Any]] = field(default_factory=lambda: list(TELNYX_DEFAULT_ICE_SERVERS)) + + +_dtls_session_tickets_disabled = False + + +def _disable_dtls_session_tickets() -> None: + """Stop aiortc's DTLS server from emitting a NewSessionTicket. + + OpenSSL's DTLS server sends NewSessionTicket + ChangeCipherSpec + Finished as its + final flight. aiortc never sets SSL_OP_NO_TICKET, so that ticket (~1.4 kB) has to be + fragmented across several records. Telnyx's FreeSWITCH does not process the fragmented + ticket, so it never consumes our Finished: it re-sends its own final flight, never + completes the handshake, and never derives SRTP keys. OpenSSL considers the handshake + done on our side, so aiortc reports "connected" while media is silently dead in BOTH + directions -- the assistant hears nothing and sends us nothing. + + Session resumption is meaningless for a single DTLS-SRTP call, and browsers do not + offer tickets here either, so disabling them costs nothing and makes the final flight + a single unfragmented CCS + Finished. + """ + global _dtls_session_tickets_disabled + if _dtls_session_tickets_disabled: + return + + from aiortc.rtcdtlstransport import RTCCertificate + from OpenSSL import SSL + + original = RTCCertificate._create_ssl_context + + def _create_ssl_context(self: Any, srtp_profiles: Any) -> Any: + ctx = original(self, srtp_profiles) + ctx.set_options(SSL.OP_NO_TICKET) + return ctx + + RTCCertificate._create_ssl_context = _create_ssl_context # type: ignore[method-assign] + _dtls_session_tickets_disabled = True + + +def _load_aiortc_modules() -> tuple[Any, Any, Any, Any]: + try: + from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription + from av import AudioFrame + except ImportError as exc: + raise ValueError( + "Direct Telnyx WebRTC mode requires aiortc at runtime. " + "Install aiortc in the EVA environment to open the Telnyx media session." + ) from exc + _disable_dtls_session_tickets() + return MediaStreamTrack, RTCPeerConnection, RTCSessionDescription, AudioFrame + + +def _normalize_telnyx_headers(value: Any) -> list[dict[str, str]]: + if value is None: + return [] + if isinstance(value, dict): + return [{"name": str(name), "value": str(header_value)} for name, header_value in value.items()] + if not isinstance(value, list): + raise ValueError("custom_headers must be a mapping or a list of name/value objects") + + headers: list[dict[str, str]] = [] + for item in value: + if not isinstance(item, dict): + raise ValueError("custom_headers list entries must be objects") + name = item.get("name") + header_value = item.get("value") + if name is None or header_value is None: + raise ValueError("custom_headers entries must include name and value") + headers.append({"name": str(name), "value": str(header_value)}) + return headers + + +def _require_dict(value: Any, name: str) -> dict[str, Any]: + if value is None: + return {} + if not isinstance(value, dict): + raise ValueError(f"{name} must be a JSON object") + return dict(value) + + +class TelnyxVertoJsonRpcClient: + """Minimal Verto JSON-RPC client for Telnyx anonymous WebRTC sessions.""" + + def __init__(self, host: str): + self.host = host + self._ws: Any | None = None + self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {} + self._receive_task: asyncio.Task[None] | None = None + self._request_handler: Callable[[dict[str, Any]], Awaitable[None]] | None = None + + @property + def connected(self) -> bool: + return self._ws is not None + + async def connect(self, request_handler: Callable[[dict[str, Any]], Awaitable[None]]) -> None: + try: + import websockets + except ImportError as exc: + raise ValueError("Direct Telnyx WebRTC mode requires the websockets package") from exc + + self._request_handler = request_handler + self._ws = await websockets.connect(self.host) + self._receive_task = asyncio.create_task(self._receive_loop()) + + async def close(self) -> None: + if self._receive_task is not None: + self._receive_task.cancel() + try: + await self._receive_task + except asyncio.CancelledError: + pass + self._receive_task = None + + if self._ws is not None: + await self._ws.close() + self._ws = None + + for future in self._pending.values(): + if not future.done(): + future.cancel() + self._pending.clear() + + async def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + if self._ws is None: + raise RuntimeError("Telnyx Verto websocket is not connected") + + request_id = str(uuid.uuid4()) + loop = asyncio.get_running_loop() + future: asyncio.Future[dict[str, Any]] = loop.create_future() + self._pending[request_id] = future + await self._ws.send(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})) + return await future + + async def reply(self, request_id: Any, result: dict[str, Any]) -> None: + if self._ws is None: + return + await self._ws.send(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result})) + + async def _receive_loop(self) -> None: + if self._ws is None: + return + try: + async for raw in self._ws: + try: + message = json.loads(raw) + except json.JSONDecodeError: + logger.warning("Received non-JSON Telnyx Verto message") + continue + await self._dispatch_message(message) + except asyncio.CancelledError: + pass + except Exception as exc: + for future in self._pending.values(): + if not future.done(): + future.set_exception(exc) + logger.info("Telnyx Verto websocket closed: %s", exc) + + async def _dispatch_message(self, message: dict[str, Any]) -> None: + request_id = str(message.get("id", "")) + if request_id in self._pending and ("result" in message or "error" in message): + future = self._pending.pop(request_id) + if "error" in message: + future.set_exception(RuntimeError(f"Telnyx Verto error: {message['error']}")) + else: + result = message.get("result") + future.set_result(result if isinstance(result, dict) else {}) + return + + if "method" in message and self._request_handler is not None: + await self._request_handler(message) + + +class TelnyxWebRTCClient: + """Direct Telnyx anonymous WebRTC client shaped after the Telnyx JS SDK.""" + + def __init__( + self, + *, + config: TelnyxDirectSessionConfig, + audio_handler: Callable[[bytes], Awaitable[None]], + ): + self.config = config + self.audio_handler = audio_handler + + self.session_id: str | None = None + self.call_id = str(uuid.uuid4()) + self.disconnected_event = asyncio.Event() + + self._rpc = TelnyxVertoJsonRpcClient(config.host) + self._peer: Any | None = None + self._audio_track: Any | None = None + self._remote_audio_task: asyncio.Task[None] | None = None + self._remote_description_set = asyncio.Event() + self._conversation_event_handler: Callable[[dict[str, Any]], Awaitable[None]] | None = None + + @property + def eva_call_id(self) -> str: + return self.call_id + + @staticmethod + def build_anonymous_login_params(config: TelnyxDirectSessionConfig) -> dict[str, Any]: + params: dict[str, Any] = { + "target_type": "ai_assistant", + "target_id": config.assistant_id, + "userVariables": config.user_variables, + "reconnection": False, + "User-Agent": { + "sdkVersion": TELNYX_USER_AGENT, + "data": "EVA assistant server", + }, + } + if config.target_version_id: + params["target_version_id"] = config.target_version_id + if config.target_params: + params["target_params"] = config.target_params + return params + + def build_invite_params(self, sdp: str) -> dict[str, Any]: + if not self.session_id: + raise RuntimeError("Telnyx session id is not available") + + dialog_params: dict[str, Any] = { + "callID": self.call_id, + "caller_id_name": self.config.caller_name, + "caller_id_number": self.config.caller_number, + "destination_number": self.config.destination_number, + "audio": True, + } + if self.config.client_state: + dialog_params["client_state"] = self.config.client_state + if self.config.custom_headers: + dialog_params["custom_headers"] = self.config.custom_headers + + return { + "sessid": self.session_id, + "sdp": sdp, + "dialogParams": dialog_params, + "User-Agent": TELNYX_USER_AGENT, + } + + async def start(self) -> None: + _, RTCPeerConnection, RTCSessionDescription, AudioFrame = _load_aiortc_modules() + await self._rpc.connect(self._handle_verto_request) + + login_result = await self._rpc.request( + "anonymous_login", + self.build_anonymous_login_params(self.config), + ) + self.session_id = str(login_result.get("sessid") or "").strip() + if not self.session_id: + raise RuntimeError(f"Telnyx anonymous_login response did not include sessid: {login_result}") + + self._peer = self._create_peer_connection(RTCPeerConnection) + self._audio_track = self._build_audio_track(AudioFrame) + self._peer.addTrack(self._audio_track) + + @self._peer.on("track") + def on_track(track: Any) -> None: + if track.kind == "audio": + logger.info("Telnyx WebRTC remote audio track received") + self._remote_audio_task = asyncio.create_task(self._receive_remote_audio(track)) + + @self._peer.on("iceconnectionstatechange") + def on_ice_connection_state_change() -> None: + logger.info( + "Telnyx WebRTC iceConnectionState=%s", + getattr(self._peer, "iceConnectionState", None), + ) + + @self._peer.on("connectionstatechange") + def on_connection_state_change() -> None: + state = getattr(self._peer, "connectionState", None) + logger.info("Telnyx WebRTC connectionState=%s", state) + # A failed/closed transport will never deliver media; surface it + # immediately instead of idling until the far end hangs up. + if state in {"failed", "closed"}: + self.disconnected_event.set() + + offer = await self._peer.createOffer() + await self._peer.setLocalDescription(offer) + await self._wait_for_ice_gathering_complete() + + if self._peer is None: + # stop() ran while we were gathering ICE (it nulls _peer). The session was torn + # down before the invite went out -- e.g. the user simulator failed to start -- + # so there is nothing to invite. Exit quietly instead of dereferencing None. + logger.info("Telnyx WebRTC session stopped during startup; skipping invite") + return + + local_description = self._peer.localDescription + logger.info("Telnyx WebRTC SDP offer:\n%s", local_description.sdp) + invite_result = await self._rpc.request("telnyx_rtc.invite", self.build_invite_params(local_description.sdp)) + result_sdp = invite_result.get("sdp") + logger.info("Telnyx WebRTC invite result keys: %s", list(invite_result.keys())) + if isinstance(result_sdp, str) and result_sdp: + logger.info("Telnyx WebRTC SDP answer (from invite):\n%s", result_sdp) + await self._set_remote_description(RTCSessionDescription, result_sdp) + else: + logger.warning("Telnyx WebRTC invite returned no SDP; waiting for media/answer event") + + def _create_peer_connection(self, RTCPeerConnection: Any) -> Any: + from aiortc import RTCConfiguration, RTCIceServer + + ice_servers = [ + RTCIceServer( + urls=server["urls"], + username=server.get("username"), + credential=server.get("credential"), + ) + for server in self.config.ice_servers + ] + return RTCPeerConnection(RTCConfiguration(iceServers=ice_servers)) + + async def stop(self) -> None: + if self.session_id: + try: + await self._rpc.request( + "telnyx_rtc.bye", + { + "sessid": self.session_id, + "dialogParams": {"callID": self.call_id}, + }, + ) + except Exception as exc: + logger.debug("Ignoring Telnyx bye error: %s", exc) + + if self._remote_audio_task is not None: + self._remote_audio_task.cancel() + try: + await self._remote_audio_task + except asyncio.CancelledError: + pass + self._remote_audio_task = None + + if self._peer is not None: + await self._peer.close() + self._peer = None + + await self._rpc.close() + self.disconnected_event.set() + + async def send_audio(self, pcm_16k: bytes) -> None: + if self._audio_track is None or not pcm_16k: + return + await self._audio_track.enqueue_pcm(pcm_16k) + + async def _handle_verto_request(self, message: dict[str, Any]) -> None: + method = str(message.get("method") or "") + params = message.get("params") if isinstance(message.get("params"), dict) else {} + request_id = message.get("id") + + if request_id is not None: + await self._rpc.reply(request_id, {"method": method}) + + if method in {"telnyx_rtc.media", "telnyx_rtc.answer"}: + sdp = params.get("sdp") + if isinstance(sdp, str) and sdp: + _, _, RTCSessionDescription, _ = _load_aiortc_modules() + await self._set_remote_description(RTCSessionDescription, sdp) + elif method == "telnyx_rtc.bye": + self.disconnected_event.set() + elif method == "ai_conversation": + if self._conversation_event_handler is not None: + await self._conversation_event_handler(params) + elif method == "telnyx_rtc.ping": + logger.debug("Received Telnyx Verto ping") + else: + logger.debug("Unhandled Telnyx Verto method: %s", method) + + async def _set_remote_description(self, RTCSessionDescription: Any, sdp: str) -> None: + if self._peer is None or self._remote_description_set.is_set(): + return + logger.info("Telnyx WebRTC SDP answer (setting remote description):\n%s", sdp) + await self._peer.setRemoteDescription(RTCSessionDescription(sdp=sdp, type="answer")) + self._remote_description_set.set() + logger.info( + "Telnyx WebRTC remote description set. Receivers: %s, Senders: %s, Transceivers: %s", + len(self._peer.getReceivers()), + len(self._peer.getSenders()), + len(self._peer.getTransceivers()), + ) + for i, t in enumerate(self._peer.getTransceivers()): + mid = getattr(t, "mid", None) + direction = getattr(t, "currentDirection", None) or getattr(t, "direction", None) + stopped = getattr(t, "stopped", False) + receiver_track = getattr(t.receiver, "track", None) if t.receiver else None + sender_track = getattr(t.sender, "track", None) if t.sender else None + logger.info( + " Transceiver[%s] mid=%s direction=%s stopped=%s receiver_track=%s sender_track=%s", + i, + mid, + direction, + stopped, + getattr(receiver_track, "kind", None), + getattr(sender_track, "kind", None), + ) + self._ensure_remote_audio_task() + + def _ensure_remote_audio_task(self) -> None: + if self._peer is None or self._remote_audio_task is not None: + return + for receiver in self._peer.getReceivers(): + track = getattr(receiver, "track", None) + if track is not None and getattr(track, "kind", None) == "audio": + logger.info("Telnyx WebRTC remote audio receiver attached") + self._remote_audio_task = asyncio.create_task(self._receive_remote_audio(track)) + return + + async def _wait_for_ice_gathering_complete(self, timeout_seconds: float = 10.0) -> None: + if self._peer is None or self._peer.iceGatheringState == "complete": + return + + complete = asyncio.Event() + + @self._peer.on("icegatheringstatechange") + def on_ice_gathering_state_change() -> None: + if self._peer and self._peer.iceGatheringState == "complete": + complete.set() + + try: + await asyncio.wait_for(complete.wait(), timeout=timeout_seconds) + except TimeoutError: + logger.warning("Timed out waiting for Telnyx WebRTC ICE gathering to complete; sending current SDP") + + def _build_audio_track(self, AudioFrame: Any) -> Any: + MediaStreamTrack, _, _, _ = _load_aiortc_modules() + + class _QueuedPcmAudioTrack(MediaStreamTrack): + kind = "audio" + + def __init__(self) -> None: + super().__init__() + self._queue: asyncio.Queue[bytes] = asyncio.Queue() + self._pts = 0 + self._sample_rate = TELNYX_SAMPLE_RATE + self._silence_frame = b"\x00" * int(TELNYX_SAMPLE_RATE * MULAW_CHUNK_DURATION_S * PCM_SAMPLE_WIDTH) + + async def enqueue_pcm(self, pcm_16k: bytes) -> None: + await self._queue.put(pcm_16k) + + async def recv(self) -> Any: + try: + pcm_16k = await asyncio.wait_for(self._queue.get(), timeout=MULAW_CHUNK_DURATION_S) + except TimeoutError: + pcm_16k = self._silence_frame + samples = max(1, len(pcm_16k) // PCM_SAMPLE_WIDTH) + frame = AudioFrame(format="s16", layout="mono", samples=samples) + frame.planes[0].update(pcm_16k) + frame.sample_rate = self._sample_rate + frame.pts = self._pts + frame.time_base = Fraction(1, self._sample_rate) + self._pts += samples + return frame + + return _QueuedPcmAudioTrack() + + async def _receive_remote_audio(self, track: Any) -> None: + frames = 0 + try: + while True: + try: + frame = await asyncio.wait_for(track.recv(), timeout=10.0) + except TimeoutError: + # recv() blocks when no RTP is arriving. If the media path + # never establishes this repeats until the far end hangs up, + # producing empty audio buffers and a silent conversation. + logger.warning( + "Telnyx WebRTC: no remote audio frame in 10s " + "(iceConnectionState=%s, frames_so_far=%d) — media not flowing", + getattr(self._peer, "iceConnectionState", None), + frames, + ) + # Log receiver stats to distinguish "no RTP arriving" vs + # "arriving but not decoding". + if self._peer is not None: + for r in self._peer.getReceivers(): + try: + stats = await r.getStats() + for report in stats.values(): + if hasattr(report, "packetsReceived"): + logger.info( + " Receiver stats: packetsReceived=%s " + "bytesReceived=%s packetsLost=%s " + "jitter=%s kind=%s", + getattr(report, "packetsReceived", None), + getattr(report, "bytesReceived", None), + getattr(report, "packetsLost", None), + getattr(report, "jitter", None), + getattr(report, "kind", None), + ) + except Exception as exc: + logger.debug(" Receiver stats error: %s", exc) + continue + frames += 1 + if frames == 1: + logger.info( + "Telnyx WebRTC first remote audio frame received (sample_rate=%s, samples=%s)", + getattr(frame, "sample_rate", None), + getattr(frame, "samples", None), + ) + pcm_16k = self._frame_to_pcm16_16k(frame) + if pcm_16k: + await self.audio_handler(pcm_16k) + except asyncio.CancelledError: + pass + except Exception as exc: + logger.info("Telnyx remote audio track stopped after %d frames: %s", frames, exc) + self.disconnected_event.set() + + @staticmethod + def _frame_to_pcm16_16k(frame: Any) -> bytes: + sample_rate = int(getattr(frame, "sample_rate", TELNYX_SAMPLE_RATE) or TELNYX_SAMPLE_RATE) + try: + pcm = frame.to_ndarray().astype("int16").tobytes() + except Exception: + pcm = bytes(frame.planes[0]) + if sample_rate != TELNYX_SAMPLE_RATE: + pcm, _ = audioop.ratecv(pcm, PCM_SAMPLE_WIDTH, 1, sample_rate, TELNYX_SAMPLE_RATE, None) + return pcm + + +class TelnyxCallControlTransport: + """Telnyx Call Control transport using bidirectional media streaming.""" + + def __init__( + self, + *, + api_key: str, + to: str, + connection_id: str, + from_number: str, + conversation_id: str, + webhook_base_url: str, + api_v2_base: str, + audio_handler: Callable[[bytes], Awaitable[None]], + connect_timeout_seconds: float = 60.0, + ): + self.api_key = api_key + self.to = to + self.connection_id = connection_id + self.from_number = from_number + self.conversation_id = conversation_id + self.webhook_base_url = webhook_base_url.rstrip("/") + self.api_v2_base = api_v2_base.rstrip("/") + self.audio_handler = audio_handler + self.connect_timeout_seconds = connect_timeout_seconds + + self._session: aiohttp.ClientSession | None = None + self._stream_ws: WebSocket | None = None + self._call_control_id: str | None = None + self._call_session_id: str | None = None + self._call_leg_id: str | None = None + self._eva_call_id: str | None = None + self._stream_id: str | None = None + self._connected_event = asyncio.Event() + self._disconnected_event = asyncio.Event() + self._send_lock = asyncio.Lock() + self._inbound_encoding = "L16" + self._inbound_sample_rate = TELNYX_SAMPLE_RATE + + @property + def call_control_id(self) -> str | None: + return self._call_control_id + + @property + def call_session_id(self) -> str | None: + return self._call_session_id + + @property + def call_leg_id(self) -> str | None: + return self._call_leg_id + + @property + def eva_call_id(self) -> str | None: + return self._eva_call_id + + @property + def disconnected_event(self) -> asyncio.Event: + return self._disconnected_event + + async def start(self) -> None: + if self._session is not None: + logger.warning("Telnyx transport already started for %s", self.to) + return + + self._connected_event.clear() + self._disconnected_event.clear() + self._session = aiohttp.ClientSession( + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + timeout=aiohttp.ClientTimeout(total=30.0), + ) + self._eva_call_id = str(uuid.uuid4()) + payload = self.build_call_payload() + logger.info("Placing Telnyx Call Control call to %s, eva_call_id=%s", self.to, self._eva_call_id) + + response = await self._post("/calls", payload) + data = response.get("data", {}) + self._call_control_id = data.get("call_control_id") + self._call_session_id = data.get("call_session_id") + self._call_leg_id = data.get("call_leg_id") + if not self._call_control_id: + raise RuntimeError("Telnyx call creation response did not include data.call_control_id") + if not self._call_session_id: + raise RuntimeError("Telnyx call creation response did not include data.call_session_id") + + await asyncio.wait_for(self._connected_event.wait(), timeout=self.connect_timeout_seconds) + logger.info("Telnyx media stream connected for conversation %s", self.conversation_id) + + def build_call_payload(self) -> dict[str, Any]: + stream_base = self.webhook_base_url.replace("https://", "wss://").replace("http://", "ws://") + stream_wss_url = f"{stream_base}/media-stream/{self.conversation_id}" + return { + "connection_id": self.connection_id, + "to": self.to, + "from": self.from_number, + "stream_url": stream_wss_url, + "stream_track": "both_tracks", + "stream_bidirectional_mode": "rtp", + "stream_bidirectional_codec": "L16", + "stream_bidirectional_sampling_rate": TELNYX_SAMPLE_RATE, + "custom_headers": [ + { + "name": "X-Eva-Call-Id", + "value": self._eva_call_id or "", + } + ], + } + + async def stop(self) -> None: + if self._call_control_id and not self._disconnected_event.is_set(): + try: + await self._post(f"/calls/{self._call_control_id}/actions/hangup", {}) + except Exception as exc: + logger.warning("Failed to hang up Telnyx call %s: %s", self._call_control_id, exc) + + if self._stream_ws is not None: + try: + await self._stream_ws.close() + except Exception as exc: + logger.debug("Ignoring Telnyx media stream close error: %s", exc) + self._stream_ws = None + + if self._session is not None: + await self._session.close() + self._session = None + + self._connected_event.clear() + + async def send_audio(self, pcm_16k: bytes) -> None: + if not pcm_16k: + return + async with self._send_lock: + if self._stream_ws is None: + logger.debug("Dropping outbound Telnyx audio because media stream is not connected") + return + try: + outbound = self._convert_outbound_audio(pcm_16k) + await self._stream_ws.send_text( + json.dumps( + { + "event": "media", + "media": { + "payload": base64.b64encode(outbound).decode("ascii"), + }, + } + ) + ) + except Exception: + logger.info("Telnyx media stream disconnected while sending audio for %s", self.to) + + async def handle_media_stream(self, websocket: WebSocket) -> None: + self._stream_ws = websocket + self._connected_event.set() + self._disconnected_event.clear() + + try: + while True: + raw_message = await websocket.receive_text() + try: + message = json.loads(raw_message) + except json.JSONDecodeError: + logger.warning("Received non-JSON Telnyx media stream message") + continue + + event_type = message.get("event") + if event_type == "media": + payload_b64 = message.get("media", {}).get("payload") + if payload_b64: + raw_audio = base64.b64decode(payload_b64) + await self.audio_handler(self._convert_inbound_audio(raw_audio)) + elif event_type == "start": + self._stream_id = message.get("stream_id") + media_format = message.get("start", {}).get("media_format", {}) + if isinstance(media_format, dict) and media_format: + self._inbound_encoding = media_format.get("encoding", "L16") + self._inbound_sample_rate = int(media_format.get("sample_rate", TELNYX_SAMPLE_RATE)) + logger.info( + "Telnyx media stream started: stream_id=%s codec=%s@%s", + self._stream_id, + self._inbound_encoding, + self._inbound_sample_rate, + ) + elif event_type == "stop": + logger.info("Telnyx media stream stopped for conversation %s", self.conversation_id) + break + elif event_type == "connected": + logger.debug("Telnyx media stream connected event for %s", self.conversation_id) + else: + logger.debug("Unknown Telnyx media stream event: %s", event_type) + finally: + self._stream_ws = None + self._disconnected_event.set() + + def _convert_inbound_audio(self, raw_audio: bytes) -> bytes: + enc = self._inbound_encoding + rate = self._inbound_sample_rate + if enc in {"PCMU", "audio/x-mulaw"}: + pcm_audio = audioop.ulaw2lin(raw_audio, PCM_SAMPLE_WIDTH) + if rate and rate != TELNYX_SAMPLE_RATE: + pcm_audio, _ = audioop.ratecv(pcm_audio, PCM_SAMPLE_WIDTH, 1, rate, TELNYX_SAMPLE_RATE, None) + return pcm_audio + if enc in {"L16", "audio/L16"}: + if rate and rate != TELNYX_SAMPLE_RATE: + pcm_audio, _ = audioop.ratecv(raw_audio, PCM_SAMPLE_WIDTH, 1, rate, TELNYX_SAMPLE_RATE, None) + return pcm_audio + return raw_audio + return raw_audio + + def _convert_outbound_audio(self, pcm_16k: bytes) -> bytes: + if self._inbound_sample_rate >= TELNYX_SAMPLE_RATE: + return pcm_16k + converted, _ = audioop.ratecv( + pcm_16k, + PCM_SAMPLE_WIDTH, + 1, + TELNYX_SAMPLE_RATE, + self._inbound_sample_rate, + None, + ) + return converted + + async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + if self._session is None: + raise RuntimeError("Telnyx HTTP session is not initialized") + async with self._session.post(f"{self.api_v2_base}{path}", json=payload) as response: + try: + body = await response.json() + except aiohttp.ContentTypeError: + body = {"message": await response.text()} + if response.status >= 400: + raise RuntimeError(f"Telnyx API {response.status} at {path}: {json.dumps(body)}") + return body if isinstance(body, dict) else {"data": body} + + +class TelnyxAssistantServer(AbstractAssistantServer): + """Bridge EVA's Twilio-framed local simulator to a hosted Telnyx assistant.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._audio_sample_rate = RECORDING_SAMPLE_RATE + + self.s2s_params = self.pipeline_config.s2s_params or {} + self._transport_pref = str(self.s2s_params.get("transport") or "").strip().lower() + self._api_key = self.s2s_params.get("api_key") or self.s2s_params.get("telnyx_api_key") or "" + self._api_v2_base = _normalize_api_v2_base(self.s2s_params.get("api_base")) + self._webhook_base_url = ( + self.s2s_params.get("webhook_base_url") or self.s2s_params.get("public_base_url") or "" + ).rstrip("/") + # "auto" (or auto_tunnel: true) => bring up a Cloudflare quick tunnel for this run and + # use its public URL as webhook_base_url. EVA is one-shot, so the tunnel lives exactly + # as long as the run. Needs the `cloudflared` binary; needs no Cloudflare account. + self._auto_tunnel = bool(self.s2s_params.get("auto_tunnel")) or self._webhook_base_url == "auto" + if self._webhook_base_url == "auto": + self._webhook_base_url = "" + self._tunnel_cm: Any | None = None + self._assistant_id = self.s2s_params.get("assistant_id") or self.s2s_params.get("assistant_agent_id") or "" + # create_assistant defaults to True: on each run EVA provisions a fresh assistant from the + # selected domain's agent config (instructions + tools wired to EVA's own /tools webhook) and + # deletes it on shutdown. Supplying an explicit assistant_id opts out (reuse that assistant); + # create_assistant: false always forces reuse. + _create_assistant = self.s2s_params.get("create_assistant") + self._create_assistant = (not self._assistant_id) if _create_assistant is None else bool(_create_assistant) + self._target_version_id = self.s2s_params.get("target_version_id") or self.s2s_params.get("targetVersionId") + self._websocket_host = ( + self.s2s_params.get("host") + or self.s2s_params.get("websocket_host") + or self.s2s_params.get("base_websocket_host") + or TELNYX_PROD_RTC_HOST + ) + self._target_params = _require_dict( + self.s2s_params.get("target_params") or self.s2s_params.get("targetParams"), + "target_params", + ) + self._user_variables = _require_dict( + self.s2s_params.get("user_variables") or self.s2s_params.get("userVariables"), + "user_variables", + ) + self._caller_name = self.s2s_params.get("caller_name") or "EVA User" + self._caller_number = ( + self.s2s_params.get("caller_number") or self.s2s_params.get("from_number") or "+15555550100" + ) + self._destination_number = self.s2s_params.get("destination_number") or "ai_assistant" + self._client_state = self.s2s_params.get("client_state") or None + self._custom_headers = _normalize_telnyx_headers(self.s2s_params.get("custom_headers")) + self._model = self.s2s_params.get("model") or TelnyxAssistantManager.DEFAULT_MODEL + self._voice = self.s2s_params.get("voice") or TelnyxAssistantManager.DEFAULT_VOICE + self._stt_model = self.s2s_params.get("stt_model") or TelnyxAssistantManager.DEFAULT_STT_MODEL + + self._transport: TelnyxWebRTCClient | TelnyxCallControlTransport | None = None + self._completed_transport: TelnyxWebRTCClient | TelnyxCallControlTransport | None = None + self._created_assistant_id: str | None = None + self._conversation_map: dict[str, str] = {} + self._transcript_enriched = False + self._user_events_enriched = False + self._stream_sid = self.conversation_id + self._user_speaking = False + self._last_user_speech_stop_ts: str | None = None + self._last_assistant_audio_monotonic: float | None = None + + async def start(self) -> None: + if self._running: + logger.warning("Telnyx server already running") + return + + self.output_dir.mkdir(parents=True, exist_ok=True) + self._fw_log = FrameworkLogWriter(self.output_dir) + self._metrics_log = MetricsLogWriter(self.output_dir) + + # Bring the auto-tunnel up first: its public URL must be known before we validate (it + # decides Call Control vs WebRTC) and before we create the assistant, whose tool webhooks + # and SIP target are built from it. cloudflared assigns the URL immediately; the local + # media port need not be listening yet. + if self._auto_tunnel and not self._webhook_base_url: + await self._start_auto_tunnel() + + # Fold `python -m eva.assistant.telnyx_provisioning` into the run: from just an API key, + # discover/create the Call Control connection + a caller-ID number when they aren't supplied. + if ( + self._use_call_control + and self._api_key + and self.s2s_params.get("auto_provision", True) + and not ( + self.s2s_params.get("connection_id") + and (self.s2s_params.get("from_number") or self.s2s_params.get("caller_number")) + ) + ): + await self._auto_provision_resources() + + self._validate_runtime_config() + + if self._create_assistant: + if not self._api_key: + raise ValueError("Telnyx create_assistant requires api_key or telnyx_api_key") + manager = TelnyxAssistantManager( + self._api_key, api_base=self.s2s_params.get("api_base") or TELNYX_DEFAULT_API_BASE + ) + try: + self._created_assistant_id = await manager.create_benchmark_assistant( + agent_config=self.agent, + agent_config_path=self.agent_config_path, + webhook_base_url=self._webhook_base_url or None, + model=self._model, + voice=self._voice, + stt_model=self._stt_model, + ) + self._assistant_id = self._created_assistant_id + logger.info("Created Telnyx assistant %s", self._created_assistant_id) + finally: + await manager.close() + + # Call Control dials the assistant's SIP address; derive it from the assistant id (created + # just now or supplied) unless the caller pinned `to`/`to_number`. + if ( + self._use_call_control + and self._assistant_id + and not (self.s2s_params.get("to") or self.s2s_params.get("to_number")) + ): + self._destination_number = f"sip:anonymous@{self._assistant_id}.sip.telnyx.com" + + self._app = FastAPI() + self._register_routes(self._app) + + config = uvicorn.Config( + self._app, + host="0.0.0.0", + port=self.port, + log_level="warning", + lifespan="off", + ) + self._server = uvicorn.Server(config) + self._running = True + self._server_task = asyncio.create_task(self._server.serve()) + + while not self._server.started: + await asyncio.sleep(0.01) + + logger.info("Telnyx server started on ws://localhost:%s", self.port) + + async def _auto_provision_resources(self) -> None: + """Discover/create the Call Control connection + caller-ID number from just the API key. + + Reuse-first and idempotent (buys nothing; reuses an unattached number and an existing + outbound voice profile). Fills ``connection_id`` and ``from_number`` into ``s2s_params`` + before validation, and points the connection's webhook at the current public URL. + """ + from eva.assistant.telnyx_provisioning.provision import TelnyxProvisioner + + def _provision() -> Any: + with TelnyxProvisioner(self._api_key) as prov: + return prov.ensure( + assistant_id=self._assistant_id or "pending", + public_url=self._webhook_base_url, + from_number=self.s2s_params.get("from_number") or self.s2s_params.get("caller_number"), + create=True, + ) + + result = await asyncio.to_thread(_provision) + self.s2s_params["connection_id"] = result.connection_id + self.s2s_params["from_number"] = result.from_number + self._caller_number = result.from_number + if result.created: + logger.info("Auto-provisioned Telnyx resources: %s", ", ".join(result.created)) + logger.info("Using Telnyx connection_id=%s from_number=%s", result.connection_id, result.from_number) + + async def _start_auto_tunnel(self) -> None: + """Bring up a Cloudflare quick tunnel to this run's media port and use its URL.""" + from eva.assistant.telnyx_provisioning.tunnel import CloudflaredNotFound, cloudflare_quick_tunnel + + try: + self._tunnel_cm = cloudflare_quick_tunnel(self.port) + url = await self._tunnel_cm.__aenter__() + except CloudflaredNotFound: + self._tunnel_cm = None + raise + self._webhook_base_url = url.rstrip("/") + connection_id = self.s2s_params.get("connection_id") + if connection_id and self._api_key: + await self._point_connection_webhook(connection_id, self._webhook_base_url) + logger.info("Auto-tunnel active; webhook_base_url=%s", self._webhook_base_url) + + async def _point_connection_webhook(self, connection_id: str, base_url: str) -> None: + """Point the Call Control connection's webhook at the (per-run) tunnel URL.""" + import aiohttp + + url = f"{self._api_v2_base}/call_control_applications/{connection_id}" + payload = {"webhook_event_url": f"{base_url}/call-control-events"} + try: + async with aiohttp.ClientSession(headers={"Authorization": f"Bearer {self._api_key}"}) as s: + async with s.patch(url, json=payload) as r: + if r.status >= 400: + logger.warning("Failed to update connection webhook (%s): %s", r.status, await r.text()) + except Exception: + logger.warning("Error updating connection webhook", exc_info=True) + + async def _shutdown(self) -> None: + if not self._running: + return + self._running = False + + if self._transport is not None: + await self._transport.stop() + self._completed_transport = self._transport + self._transport = None + + if self._tunnel_cm is not None: + try: + await self._tunnel_cm.__aexit__(None, None, None) + except Exception: + logger.debug("Error tearing down auto-tunnel", exc_info=True) + self._tunnel_cm = None + + if self._created_assistant_id: + manager = TelnyxAssistantManager( + self._api_key, + api_base=self.s2s_params.get("api_base") or TELNYX_DEFAULT_API_BASE, + ) + try: + await manager.delete_assistant(self._created_assistant_id) + logger.info("Deleted Telnyx assistant %s", self._created_assistant_id) + except Exception: + logger.warning("Failed to delete Telnyx assistant %s", self._created_assistant_id, exc_info=True) + finally: + await manager.close() + + if self._server: + self._server.should_exit = True + if self._server_task: + try: + await asyncio.wait_for(self._server_task, timeout=5.0) + except TimeoutError: + self._server_task.cancel() + try: + await self._server_task + except asyncio.CancelledError: + pass + except (asyncio.CancelledError, KeyboardInterrupt): + pass + self._server = None + self._server_task = None + + logger.info("Telnyx server stopped on port %s", self.port) + + async def save_outputs(self) -> None: + await self._enrich_audit_log_from_external_sources() + await super().save_outputs() + + def get_conversation_stats(self) -> dict[str, Any]: + self._append_user_events_from_simulator() + return super().get_conversation_stats() + + @property + def _use_call_control(self) -> bool: + """Whether to use Call Control (media streaming) transport instead of WebRTC.""" + if self._transport_pref == "media_streaming": + return True + return bool(self.s2s_params.get("connection_id") and self._webhook_base_url) + + def _validate_runtime_config(self) -> None: + missing: list[str] = [] + if self._use_call_control: + if not self._api_key: + missing.append("api_key or telnyx_api_key") + if not self.s2s_params.get("connection_id"): + missing.append("connection_id") + if not (self.s2s_params.get("from_number") or self.s2s_params.get("caller_number")): + missing.append("from_number or caller_number") + if not ( + self.s2s_params.get("to") + or self.s2s_params.get("to_number") + or self.s2s_params.get("destination_number") + or self._assistant_id + or self._create_assistant + ): + missing.append("to or to_number or destination_number") + if not self._webhook_base_url: + missing.append("webhook_base_url or public_base_url") + if missing: + raise ValueError("Telnyx Call Control mode requires s2s_params: " + ", ".join(missing) + ".") + else: + if not self._assistant_id and not self._create_assistant: + missing.append("assistant_id or assistant_agent_id") + if self._create_assistant and not self._api_key: + missing.append("api_key or telnyx_api_key") + if missing: + raise ValueError( + "Telnyx direct WebRTC mode requires s2s_params: " + + ", ".join(missing) + + ". Call Control fields such as webhook_base_url, connection_id, from_number, " + "and to/sip_uri are not required for direct mode." + ) + + def _register_routes(self, app: FastAPI) -> None: + @app.websocket("/ws") + async def websocket_endpoint(websocket: WebSocket) -> None: + await websocket.accept() + await self._handle_session(websocket) + + @app.websocket("/") + async def websocket_root(websocket: WebSocket) -> None: + await websocket.accept() + await self._handle_session(websocket) + + @app.websocket("/media-stream/{conversation_id:path}") + async def media_stream(websocket: WebSocket, conversation_id: str) -> None: + if not isinstance(self._transport, TelnyxCallControlTransport) or conversation_id != self.conversation_id: + await websocket.close(code=1008, reason="No active Telnyx transport for this conversation") + return + await websocket.accept() + try: + await self._transport.handle_media_stream(websocket) + except WebSocketDisconnect: + logger.info("Telnyx media stream WebSocket disconnected for %s", conversation_id) + + @app.post("/tools/{eva_call_id}/{tool_name}") + async def invoke_tool(eva_call_id: str, tool_name: str, request: Request) -> Any: + transport_call_id = self._completed_transport.eva_call_id if self._completed_transport else None + active_call_id = self._transport.eva_call_id if self._transport else transport_call_id + if active_call_id and eva_call_id != active_call_id: + logger.warning("Tool webhook route id mismatch: got=%s expected=%s", eva_call_id, active_call_id) + raise HTTPException(status_code=404, detail=f"Unknown call_id: {eva_call_id}") + + try: + body = await request.json() + params = _extract_tool_params(body) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=400, detail="Invalid JSON request body") from exc + + result = await self.execute_tool(tool_name, params) + if tool_name == "end_call": + logger.info("end_call tool invoked for Telnyx call %s", eva_call_id) + asyncio.create_task(self._hangup_active_call()) + return result + + @app.post("/dynamic-variables") + async def dynamic_variables(request: Request) -> dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + return self._handle_dynamic_variables_payload(body) + + @app.post("/call-control-events") + async def call_control_events(request: Request) -> dict[str, str]: + return {"status": "ok"} + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + async def _handle_session(self, websocket: WebSocket) -> None: + logger.info("Client connected to Telnyx server") + audio_output_queue: asyncio.Queue[bytes] = asyncio.Queue() + + async def on_telnyx_audio(pcm_16k: bytes) -> None: + await self._on_telnyx_audio(pcm_16k, audio_output_queue) + + if self._use_call_control: + logger.info( + "Using Telnyx Call Control transport (connection_id=%s, to=%s)", + self.s2s_params.get("connection_id"), + self.s2s_params.get("to") or self.s2s_params.get("to_number") or self._destination_number, + ) + self._transport = TelnyxCallControlTransport( + api_key=self._api_key, + to=self.s2s_params.get("to") or self.s2s_params.get("to_number") or self._destination_number, + connection_id=self.s2s_params["connection_id"], + from_number=self._caller_number, + conversation_id=self.conversation_id, + webhook_base_url=self._webhook_base_url, + api_v2_base=self._api_v2_base, + audio_handler=on_telnyx_audio, + ) + else: + self._transport = TelnyxWebRTCClient( + config=self._build_direct_session_config(), + audio_handler=on_telnyx_audio, + ) + self._transport._conversation_event_handler = self._handle_telnyx_conversation_event + self._completed_transport = self._transport + + pacer_task = asyncio.create_task(self._pace_audio_output(websocket, audio_output_queue)) + forward_task: asyncio.Task[None] | None = None + disconnect_task: asyncio.Task[bool] | None = None + + try: + await self._transport.start() + if self._transport.eva_call_id: + logger.info("Telnyx direct WebRTC call id: %s", self._transport.eva_call_id) + + forward_task = asyncio.create_task(self._forward_user_audio(websocket, self._transport)) + disconnect_task = asyncio.create_task(self._transport.disconnected_event.wait()) + done, pending = await asyncio.wait( + [forward_task, disconnect_task, pacer_task], + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + for task in done: + task_exc = task.exception() + if task is not pacer_task and task_exc is not None: + raise task_exc + except Exception as exc: + logger.error("Telnyx session error: %s", exc, exc_info=True) + finally: + if forward_task is not None and not forward_task.done(): + forward_task.cancel() + if disconnect_task is not None and not disconnect_task.done(): + disconnect_task.cancel() + pacer_task.cancel() + try: + await pacer_task + except asyncio.CancelledError: + pass + if self._transport is not None: + await self._transport.stop() + logger.info("Client disconnected from Telnyx server") + + async def _handle_telnyx_conversation_event(self, params: dict[str, Any]) -> None: + event_type = params.get("type") + item = params.get("item") if isinstance(params.get("item"), dict) else {} + if event_type != "conversation.item.created" or not item: + return + + role = item.get("role") + text = _conversation_item_text(item) + if not text: + return + + timestamp_ms = _wall_ms() + if role == "assistant": + self.audit_log.append_assistant_output(text, timestamp_ms=timestamp_ms) + if self._fw_log: + self._fw_log.llm_response(text, timestamp_ms=int(timestamp_ms)) + self._fw_log.turn_end(was_interrupted=False) + elif role == "user": + self.audit_log.append_user_input(text, timestamp_ms=timestamp_ms) + else: + logger.debug("Ignoring Telnyx conversation item role=%s text=%s", role, text[:80]) + + def _build_direct_session_config(self) -> TelnyxDirectSessionConfig: + if not self._assistant_id: + raise ValueError("Telnyx direct WebRTC mode requires assistant_id or assistant_agent_id") + return TelnyxDirectSessionConfig( + assistant_id=self._assistant_id, + host=self._websocket_host, + target_version_id=self._target_version_id, + target_params=self._target_params, + user_variables=self._user_variables, + caller_name=str(self._caller_name), + caller_number=str(self._caller_number), + destination_number=str(self._destination_number), + client_state=str(self._client_state) if self._client_state else None, + custom_headers=self._custom_headers, + ) + + def _build_telnyx_tool_definitions(self) -> list[dict[str, Any]]: + return [self._build_telnyx_tool_definition(tool) for tool in self.agent.tools] + + @staticmethod + def _build_telnyx_tool_definition(tool: AgentTool) -> dict[str, Any]: + return { + "type": "client_tool", + "name": tool.id, + "description": tool.description, + "parameters": { + "type": "object", + "properties": tool.get_parameter_properties(), + "required": tool.get_required_param_names(), + }, + } + + async def _execute_telnyx_tool_call(self, tool_name: str, arguments: dict[str, Any]) -> Any: + logger.warning("Telnyx direct client tool execution is stubbed and not yet wired to Verto events") + return await self.execute_tool(tool_name, arguments) + + async def _forward_user_audio( + self, + websocket: WebSocket, + transport: TelnyxWebRTCClient | TelnyxCallControlTransport, + ) -> None: + try: + while True: + raw = await websocket.receive_text() + try: + data = json.loads(raw) + except json.JSONDecodeError: + continue + event_type = data.get("event") + + if event_type == "start": + self._stream_sid = data.get("start", {}).get("streamSid") or self.conversation_id + continue + if event_type == "stop": + break + if event_type == "user_speech_start": + self._user_speaking = True + timestamp_ms = data.get("timestamp_ms") + if self._fw_log and _is_epoch_ms(timestamp_ms): + self._fw_log.turn_start(timestamp_ms=int(timestamp_ms)) + elif self._fw_log: + self._fw_log.turn_start() + continue + if event_type == "user_speech_stop": + self._user_speaking = False + timestamp_ms = str(data.get("timestamp_ms") or "") + self._last_user_speech_stop_ts = timestamp_ms if _is_epoch_ms(timestamp_ms) else _wall_ms() + continue + if event_type != "media": + continue + + mulaw_bytes = parse_twilio_media_message(raw) + if not mulaw_bytes: + continue + + pcm_24k = mulaw_8k_to_pcm16_24k(mulaw_bytes) + if not self._is_assistant_audio_active(): + sync_buffer_to_position(self.assistant_audio_buffer, len(self.user_audio_buffer)) + self.user_audio_buffer.extend(pcm_24k) + + pcm_16k = mulaw_8k_to_pcm16_16k(mulaw_bytes) + await transport.send_audio(pcm_16k) + except WebSocketDisconnect: + logger.debug("Telnyx local WebSocket disconnected") + except asyncio.CancelledError: + pass + + async def _on_telnyx_audio(self, pcm_16k: bytes, audio_output_queue: asyncio.Queue[bytes]) -> None: + if not pcm_16k: + return + + # Telnyx streams RTP continuously (~50 pps) for the whole call, so between turns we + # decode frames of pure silence. The user simulator treats EVERY media frame as + # assistant speech -- it never inspects the payload, and infers the turn ended only + # from the ABSENCE of frames (audio_bridge._receive_from_assistant). Forwarding the + # silence therefore pins is_assistant_playing() on for the entire call: the caller's + # turn never settles, it never speaks, and the record fails with zero user turns. + # Drop silent frames so the simulator sees a real gap and can take its turn. + if audioop.rms(pcm_16k, PCM_SAMPLE_WIDTH) <= ASSISTANT_SILENCE_RMS: + return + + self._last_assistant_audio_monotonic = time.monotonic() + first_audio_wall_ms = _wall_ms() + if self._last_user_speech_stop_ts and self._metrics_log: + latency_ms = int(first_audio_wall_ms) - int(self._last_user_speech_stop_ts) + if 0 < latency_ms < 30_000: + self._metrics_log.write_latency("model_response", latency_ms / 1000, self._model) + self._last_user_speech_stop_ts = None + + pcm_24k = _pcm16_16k_to_pcm16_24k(pcm_16k) + if not self._user_speaking: + sync_buffer_to_position(self.user_audio_buffer, len(self.assistant_audio_buffer)) + self.assistant_audio_buffer.extend(pcm_24k) + + mulaw = _pcm16_16k_to_mulaw_8k(pcm_16k) + offset = 0 + while offset < len(mulaw): + chunk = mulaw[offset : offset + MULAW_CHUNK_SIZE] + offset += MULAW_CHUNK_SIZE + await audio_output_queue.put(chunk) + + async def _pace_audio_output(self, websocket: WebSocket, audio_output_queue: asyncio.Queue[bytes]) -> None: + next_send_time = time.monotonic() + try: + while True: + try: + chunk = await asyncio.wait_for(audio_output_queue.get(), timeout=1.0) + except TimeoutError: + continue + + await websocket.send_text(create_twilio_media_message(self._stream_sid, chunk)) + now = time.monotonic() + if next_send_time <= now: + next_send_time = now + next_send_time += MULAW_CHUNK_DURATION_S + sleep_duration = next_send_time - time.monotonic() + if sleep_duration > 0: + await asyncio.sleep(sleep_duration) + except asyncio.CancelledError: + pass + except Exception as exc: + logger.info("Telnyx audio pacer stopped: %s", exc) + + def _handle_dynamic_variables_payload(self, body: dict[str, Any]) -> dict[str, Any]: + payload = body.get("data", {}).get("payload", {}) if isinstance(body, dict) else {} + if not isinstance(payload, dict): + payload = {} + + eva_call_id = payload.get("eva_call_id") or (self._transport.eva_call_id if self._transport else None) + telnyx_conversation_id = payload.get("telnyx_conversation_id") + call_control_id = payload.get("call_control_id") + + logger.info( + "Telnyx dynamic variables: eva_call_id=%s conversation_id=%s call_control_id=%s", + eva_call_id, + telnyx_conversation_id, + call_control_id, + ) + + if eva_call_id and telnyx_conversation_id: + self._conversation_map[str(eva_call_id)] = str(telnyx_conversation_id) + + response: dict[str, Any] = {"dynamic_variables": {}} + if eva_call_id: + response["dynamic_variables"]["eva_call_id"] = str(eva_call_id) + metadata = {"eva_call_id": str(eva_call_id), "eva_record_id": self.conversation_id} + if self._model: + metadata["eva_llm_model"] = self._model + response["conversation"] = {"metadata": metadata} + return response + + async def _hangup_active_call(self) -> None: + if self._transport is not None: + await self._transport.stop() + + def _is_assistant_audio_active(self) -> bool: + if self._last_assistant_audio_monotonic is None: + return False + return time.monotonic() - self._last_assistant_audio_monotonic < 0.5 + + async def _enrich_audit_log_from_external_sources(self) -> None: + if self._transcript_enriched: + return + self._transcript_enriched = True + + self._append_user_events_from_simulator() + + events_path = resolve_user_simulator_events_path(self.output_dir) + assistant_messages = await self._fetch_telnyx_intended_speech() + if not assistant_messages and events_path is not None: + assistant_messages = extract_assistant_speech_events(events_path) + + for message in assistant_messages: + text = str(message.get("text", "")).strip() + timestamp_ms = str(message.get("timestamp_ms", "")) + if not text: + continue + self.audit_log.append_assistant_output(text, timestamp_ms=timestamp_ms) + if self._fw_log: + self._fw_log.llm_response(text, timestamp_ms=int(timestamp_ms) if timestamp_ms.isdigit() else None) + self._fw_log.turn_end(was_interrupted=False) + + def _append_user_events_from_simulator(self) -> None: + if self._user_events_enriched: + return + self._user_events_enriched = True + + events_path = resolve_user_simulator_events_path(self.output_dir) + if events_path is None: + logger.warning( + "No user simulator events file found in %s; audit log will have no user turns", self.output_dir + ) + return + + for user_turn in extract_user_speech_events(events_path): + timestamp_ms = user_turn.get("timestamp_ms") + self.audit_log.append_user_input(user_turn["text"], timestamp_ms=timestamp_ms) + + async def _fetch_telnyx_intended_speech(self) -> list[dict[str, Any]]: + if not self._api_key: + logger.warning("No Telnyx API key configured; skipping intended speech fetch") + return [] + + telnyx_conversation_id = self._resolve_telnyx_conversation_id() + if not telnyx_conversation_id: + logger.warning("No Telnyx conversation_id captured; skipping intended speech fetch") + return [] + + url = f"{self._api_v2_base}/ai/conversations/{telnyx_conversation_id}/messages?page[size]=100" + timeout = aiohttp.ClientTimeout(total=30.0) + try: + async with aiohttp.ClientSession( + headers={"Authorization": f"Bearer {self._api_key}"}, timeout=timeout + ) as session: + async with session.get(url) as response: + try: + payload = await response.json() + except aiohttp.ContentTypeError: + payload = {"message": await response.text()} + if response.status >= 400: + logger.warning( + "Failed to fetch Telnyx conversation messages for %s: %s %s", + telnyx_conversation_id, + response.status, + payload, + ) + return [] + except Exception as exc: + logger.warning("Failed to fetch Telnyx conversation messages for %s: %s", telnyx_conversation_id, exc) + return [] + + if not isinstance(payload, dict): + return [] + return parse_telnyx_intended_speech_payload(payload) + + def _resolve_telnyx_conversation_id(self) -> str | None: + transport = self._completed_transport or self._transport + route_id = transport.eva_call_id if transport else None + if route_id: + return self._conversation_map.get(route_id) + if len(self._conversation_map) == 1: + return next(iter(self._conversation_map.values())) + return None + + +__all__ = [ + "TelnyxAssistantManager", + "TelnyxAssistantServer", + "TelnyxCallControlTransport", + "TelnyxDirectSessionConfig", + "TelnyxVertoJsonRpcClient", + "TelnyxWebRTCClient", + "extract_assistant_speech_events", + "extract_user_speech_events", + "parse_telnyx_intended_speech_payload", +] diff --git a/src/eva/metrics/speech_fidelity_base.py b/src/eva/metrics/speech_fidelity_base.py index d6ca93e0..38cf11d2 100644 --- a/src/eva/metrics/speech_fidelity_base.py +++ b/src/eva/metrics/speech_fidelity_base.py @@ -181,6 +181,18 @@ async def _call_and_parse( used_file_upload = False uploaded_file = None # google.genai File object, set on fallback upload try: + # The audio judge is Gemini-only. When a Gemini key is available, call Gemini + # DIRECTLY via google.genai (upload the audio, use _generate_with_file) and never + # the LiteLLM router: the router has no Gemini deployment here and mis-transforms + # audio/file_id messages for Gemini. Only fall back to the router if no key is set. + if os.getenv("GEMINI_API_KEY"): + try: + uploaded_file = await self._upload_audio_file(audio_segment, context) + used_file_upload = True + except Exception as upload_err: + self.logger.error( + f"[{context.record_id}] Gemini file upload failed: {upload_err}; falling back to the router." + ) for attempt in range(1 + self.max_empty_retries): if used_file_upload and uploaded_file is not None: # Call Gemini directly via google.genai SDK — litellm's file_id diff --git a/src/eva/models/config.py b/src/eva/models/config.py index ea797e8a..a9674e6a 100644 --- a/src/eva/models/config.py +++ b/src/eva/models/config.py @@ -484,7 +484,7 @@ class ModelDeployment(DeploymentTypedDict): ) # Framework selection - framework: Literal["pipecat", "openai_realtime", "gemini_live", "elevenlabs", "grok_voice"] = Field( + framework: Literal["pipecat", "openai_realtime", "gemini_live", "elevenlabs", "grok_voice", "telnyx"] = Field( "pipecat", description=( "Agent framework to use for the assistant server." @@ -493,6 +493,7 @@ class ModelDeployment(DeploymentTypedDict): "'gemini_live': Gemini Live API via google-genai." "'elevenlabs': ElevenLabs Conversational AI API." "'grok_voice': xAI Grok voice realtime API." + "'telnyx': Telnyx hosted AI Assistant via direct anonymous WebRTC session." ), ) diff --git a/src/eva/orchestrator/worker.py b/src/eva/orchestrator/worker.py index 8183ff81..e026275d 100644 --- a/src/eva/orchestrator/worker.py +++ b/src/eva/orchestrator/worker.py @@ -49,10 +49,14 @@ def _get_server_class(framework: str) -> type[AbstractAssistantServer]: from eva.assistant.grok_voice_server import GrokVoiceAssistantServer return GrokVoiceAssistantServer + elif framework == "telnyx": + from eva.assistant.telnyx_server import TelnyxAssistantServer + + return TelnyxAssistantServer else: raise ValueError( f"Unknown framework: {framework!r}. " - "Supported: pipecat, openai_realtime, gemini_live, elevenlabs, grok_voice" + "Supported: pipecat, openai_realtime, gemini_live, elevenlabs, grok_voice, telnyx" ) diff --git a/tests/fixtures/metric_signatures.json b/tests/fixtures/metric_signatures.json index 4dc4639f..733746e2 100644 --- a/tests/fixtures/metric_signatures.json +++ b/tests/fixtures/metric_signatures.json @@ -62,13 +62,13 @@ "SpeechFidelityMetric": { "name": "agent_speech_fidelity", "prompt_hash": "c614dd12d4fe", - "source_hash": "5e61505dc729", + "source_hash": "d9c61ce8ac29", "version": "v0.4" }, "TTSFidelityMetric": { "name": "tts_fidelity", "prompt_hash": "c3a02ab03f06", - "source_hash": "10b97af355f7", + "source_hash": "62ada31835fa", "version": "v0.3" }, "TaskCompletion": { @@ -104,7 +104,7 @@ "UserSpeechFidelityMetric": { "name": "user_speech_fidelity", "prompt_hash": "849e13c42e3b", - "source_hash": "5463b901a11b", + "source_hash": "88a666dad6e2", "version": "v0.2" } } diff --git a/tests/unit/assistant/__init__.py b/tests/unit/assistant/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/assistant/test_telnyx_provisioning.py b/tests/unit/assistant/test_telnyx_provisioning.py new file mode 100644 index 00000000..51daddb7 --- /dev/null +++ b/tests/unit/assistant/test_telnyx_provisioning.py @@ -0,0 +1,188 @@ +"""Unit tests for the Telnyx media-streaming provisioner (no network).""" + +from __future__ import annotations + +import pytest + +from eva.assistant.telnyx_provisioning.provision import ( + MANAGED_APP_NAME, + ProvisionResult, + TelnyxProvisioner, + _NeedsCreate, +) + + +class _FakeResp: + def __init__(self, data, status=200, text=""): + self._data = data + self.status_code = status + self.text = text or str(data) + + def json(self): + return {"data": self._data} + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(self.text) + + +class _FakeClient: + def __init__(self, numbers, apps, profiles): + self._numbers = numbers + self._apps = apps + self._profiles = profiles + self.posts: list[tuple[str, dict]] = [] + self.patches: list[tuple[str, dict]] = [] + + def get(self, path, params=None): + if path == "/call_control_applications": + return _FakeResp(self._apps) + if path == "/outbound_voice_profiles": + return _FakeResp(self._profiles) + if path == "/phone_numbers": + return _FakeResp(self._numbers) + return _FakeResp([]) + + def post(self, path, json): + self.posts.append((path, json)) + new = {"id": f"new-{len(self.posts)}", **json} + return _FakeResp(new, status=201) + + def patch(self, path, json): + self.patches.append((path, json)) + return _FakeResp({"id": path.rsplit("/", 1)[-1], **json}) + + def close(self): + pass + + +def _prov(numbers, apps, profiles): + p = TelnyxProvisioner(api_key="test-key") + p._client = _FakeClient(numbers, apps, profiles) + return p + + +def test_requires_api_key(): + with pytest.raises(ValueError): + TelnyxProvisioner(api_key="") + + +def test_inspect_reports_missing_connection(): + prov = _prov( + numbers=[{"id": "n1", "phone_number": "+13125551234", "connection_id": None}], + apps=[], + profiles=[{"id": "prof-1", "name": "existing"}], + ) + with pytest.raises(_NeedsCreate) as exc: + prov.ensure(assistant_id="assistant-abc", public_url="https://x.example", create=False) + assert exc.value.resource == "call_control_application" + + +def test_provision_creates_connection_and_reuses_profile_and_attaches_number(): + prov = _prov( + numbers=[{"id": "n1", "phone_number": "+13125551234", "connection_id": None}], + apps=[], + profiles=[{"id": "prof-1", "name": "existing"}], + ) + result = prov.ensure(assistant_id="assistant-abc", public_url="https://x.example/", create=True) + assert isinstance(result, ProvisionResult) + assert result.from_number == "+13125551234" + assert result.to == "sip:anonymous@assistant-abc.sip.telnyx.com" + assert result.webhook_base_url == "https://x.example" + assert result.outbound_voice_profile_id == "prof-1" # reused, not created + # created the CC app and attached the number + created_paths = [p for p, _ in prov._client.posts] + assert "/call_control_applications" in created_paths + assert prov._client.patches # number attached + + +def test_prefers_unattached_number(): + prov = _prov( + numbers=[ + {"id": "a", "phone_number": "+13120000001", "connection_id": "existing-conn"}, + {"id": "b", "phone_number": "+13120000002", "connection_id": None}, + ], + apps=[ + { + "id": "app-1", + "application_name": MANAGED_APP_NAME, + "webhook_event_url": "https://x.example/call-control-events", + } + ], + profiles=[{"id": "prof-1", "name": "existing"}], + ) + result = prov.ensure(assistant_id="assistant-abc", public_url="https://x.example", create=True) + assert result.from_number == "+13120000002" # the unattached one + + +def test_idempotent_when_everything_exists(): + prov = _prov( + numbers=[{"id": "b", "phone_number": "+13120000002", "connection_id": "app-1"}], + apps=[ + { + "id": "app-1", + "application_name": MANAGED_APP_NAME, + "webhook_event_url": "https://x.example/call-control-events", + } + ], + profiles=[{"id": "prof-1", "name": "eva-media-streaming"}], + ) + result = prov.ensure(assistant_id="assistant-abc", public_url="https://x.example", create=True) + assert result.created is None # nothing created + assert not prov._client.posts + assert not prov._client.patches + + +class _FakeStderr: + def __init__(self, lines): + self._lines = list(lines) + + async def readline(self): + return self._lines.pop(0) if self._lines else b"" + + +class _FakeProc: + def __init__(self, lines): + self.stderr = _FakeStderr(lines) + self.returncode = 0 + + def terminate(self): + pass + + def kill(self): + pass + + async def wait(self): + return 0 + + +def test_tunnel_url_parse(): + import asyncio + + from eva.assistant.telnyx_provisioning.tunnel import _read_url + + proc = _FakeProc( + [ + b"2026-... INF Thank you for trying Cloudflare Tunnel.\n", + b"2026-... INF | https://brave-red-fox-1234.trycloudflare.com |\n", + ] + ) + url = asyncio.run(_read_url(proc, timeout=5)) + assert url == "https://brave-red-fox-1234.trycloudflare.com" + + +def test_tunnel_missing_binary(monkeypatch): + import asyncio + + import eva.assistant.telnyx_provisioning.tunnel as tun + + monkeypatch.setattr(tun.shutil, "which", lambda _: None) + + async def go(): + async with tun.cloudflare_quick_tunnel(10000): + pass + + import pytest + + with pytest.raises(tun.CloudflaredNotFound): + asyncio.run(go()) diff --git a/tests/unit/assistant/test_telnyx_server.py b/tests/unit/assistant/test_telnyx_server.py new file mode 100644 index 00000000..f23c41a5 --- /dev/null +++ b/tests/unit/assistant/test_telnyx_server.py @@ -0,0 +1,504 @@ +"""Unit tests for Telnyx hosted assistant helpers.""" + +import json +import time +from typing import Any, Self + +import pytest + +from eva.assistant.agentic.audit_log import AuditLog +from eva.assistant.telnyx_server import ( + TelnyxAssistantManager, + TelnyxAssistantServer, + TelnyxDirectSessionConfig, + TelnyxWebRTCClient, + _extract_tool_params, + parse_telnyx_intended_speech_payload, +) +from eva.models.agents import AgentConfig +from eva.orchestrator.worker import _get_server_class + + +class _FakeResponse: + def __init__(self, status: int, payload: dict[str, Any]): + self.status = status + self._payload = payload + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + async def json(self) -> dict[str, Any]: + return self._payload + + async def text(self) -> str: + return json.dumps(self._payload) + + +class _FakeSession: + def __init__(self) -> None: + self.requests: list[tuple[str, str, dict[str, Any] | None]] = [] + self.closed = False + + def post(self, url: str, json: dict[str, Any]) -> _FakeResponse: + self.requests.append(("POST", url, json)) + return _FakeResponse(201, {"id": "assistant-123"}) + + def delete(self, url: str) -> _FakeResponse: + self.requests.append(("DELETE", url, None)) + return _FakeResponse(200, {"id": "assistant-123", "deleted": True}) + + async def close(self) -> None: + self.closed = True + + +def _make_agent_config() -> AgentConfig: + return AgentConfig.model_validate( + { + "id": "airline-agent", + "name": "Airline Agent", + "description": "Assist callers with airline reservations.", + "role": "Reservation specialist", + "instructions": "Verify the reservation before making changes.", + "personality": "Calm and efficient.", + "tool_module_path": "eva.assistant.tools.airline_tools", + "tools": [ + { + "id": "get_reservation", + "name": "Get Reservation", + "description": "Look up a reservation by confirmation number", + "required_parameters": [ + { + "name": "confirmation_number", + "type": "string", + "description": "The booking confirmation number", + } + ], + } + ], + } + ) + + +class TestTelnyxAssistantManager: + @pytest.mark.asyncio + async def test_create_benchmark_assistant_builds_webhook_tool_payload(self) -> None: + session = _FakeSession() + manager = TelnyxAssistantManager(api_key="test-key", session=session) # type: ignore[arg-type] + + assistant_id = await manager.create_benchmark_assistant( + agent_config=_make_agent_config(), + agent_config_path="configs/agents/airline_agent.yaml", + webhook_base_url="https://example.ngrok-free.app/", + model="telnyx-llm-gpt-4o", + voice="alloy", + ) + + assert assistant_id == "assistant-123" + assert len(session.requests) == 1 + method, url, payload = session.requests[0] + assert method == "POST" + assert url == "https://api.telnyx.com/v2/ai/assistants" + assert payload is not None + assert payload["model"] == "telnyx-llm-gpt-4o" + assert payload["voice_settings"]["voice"] == "alloy" + assert payload["telephony_settings"]["supports_unauthenticated_web_calls"] is True + + webhook_tool = payload["tools"][0] + assert webhook_tool["type"] == "webhook" + webhook = webhook_tool["webhook"] + assert webhook["name"] == "get_reservation" + assert webhook["url"] == "https://example.ngrok-free.app/tools/{{eva_call_id}}/get_reservation" + assert webhook["method"] == "POST" + assert "confirmation_number" in webhook["body_parameters"]["properties"] + assert "Role: Reservation specialist" in payload["instructions"] + assert "Calm and efficient." in payload["instructions"] + + @pytest.mark.asyncio + async def test_delete_assistant_uses_delete_endpoint(self) -> None: + session = _FakeSession() + manager = TelnyxAssistantManager(api_key="test-key", session=session) # type: ignore[arg-type] + + await manager.delete_assistant("assistant-123") + + assert session.requests == [ + ("DELETE", "https://api.telnyx.com/v2/ai/assistants/assistant-123", None), + ] + + +class TestTelnyxPayloadHelpers: + def test_webhook_tool_payload_uses_eva_call_id(self) -> None: + manager = object.__new__(TelnyxAssistantManager) + payload = manager._build_assistant_payload( + agent_config=_make_agent_config(), + agent_config_path="configs/agents/airline_agent.yaml", + webhook_base_url="https://public.example", + model="model-1", + voice="voice-1", + ) + + webhook_tools = [tool for tool in payload["tools"] if tool["type"] == "webhook"] + assert len(webhook_tools) == 1 + assert "{{eva_call_id}}" in webhook_tools[0]["webhook"]["url"] + assert "{{call_control_id}}" not in webhook_tools[0]["webhook"]["url"] + assert "hangup" in [tool["type"] for tool in payload["tools"]] + + def test_direct_assistant_payload_does_not_require_public_webhook(self) -> None: + manager = object.__new__(TelnyxAssistantManager) + payload = manager._build_assistant_payload( + agent_config=_make_agent_config(), + agent_config_path="configs/agents/airline_agent.yaml", + webhook_base_url=None, + model="model-1", + voice="voice-1", + ) + + assert payload["tools"] == [ + { + "type": "hangup", + "hangup": { + "description": ( + "To be used whenever the conversation has ended " + "and it would be appropriate to hang up the call." + ) + }, + } + ] + assert "dynamic_variables_webhook_url" not in payload + assert payload["telephony_settings"]["supports_unauthenticated_web_calls"] is True + + def test_extract_tool_params_accepts_telnyx_wrapper(self) -> None: + assert _extract_tool_params({"function_params": {"pnr": "ABC123"}}) == {"pnr": "ABC123"} + + def test_extract_tool_params_accepts_plain_body(self) -> None: + assert _extract_tool_params({"pnr": "ABC123"}) == {"pnr": "ABC123"} + + def test_parse_intended_speech_reverses_telnyx_newest_first_payload(self) -> None: + payload = { + "data": [ + { + "role": "assistant", + "text": "Second response", + "sent_at": "2026-01-01T00:00:02Z", + }, + {"role": "user", "text": "Ignored", "sent_at": "2026-01-01T00:00:01Z"}, + { + "role": "assistant", + "text": "First response", + "sent_at": "2026-01-01T00:00:00Z", + }, + ] + } + + assert parse_telnyx_intended_speech_payload(payload) == [ + {"text": "First response", "timestamp_ms": 1767225600000}, + {"text": "Second response", "timestamp_ms": 1767225602000}, + ] + + +class TestTelnyxDirectWebRTC: + def test_anonymous_login_payload_targets_ai_assistant(self) -> None: + config = TelnyxDirectSessionConfig( + assistant_id="assistant-123", + target_version_id="version-1", + target_params={"locale": "en-US"}, + user_variables={"eva_record_id": "record-1"}, + ) + + payload = TelnyxWebRTCClient.build_anonymous_login_params(config) + + assert payload["target_type"] == "ai_assistant" + assert payload["target_id"] == "assistant-123" + assert payload["target_version_id"] == "version-1" + assert payload["target_params"] == {"locale": "en-US"} + assert payload["userVariables"] == {"eva_record_id": "record-1"} + assert payload["reconnection"] is False + assert payload["User-Agent"]["sdkVersion"].startswith("EVA-TelnyxWebRTC/") + + def test_invite_payload_uses_verto_dialog_shape(self) -> None: + async def noop(_: bytes) -> None: + return None + + config = TelnyxDirectSessionConfig( + assistant_id="assistant-123", + caller_name="EVA Caller", + caller_number="+15550001000", + destination_number="ignored-by-ai-assistant", + client_state="state-1", + custom_headers=[{"name": "X-Eva-Record-Id", "value": "record-1"}], + ) + client = TelnyxWebRTCClient(config=config, audio_handler=noop) + client.session_id = "sess-123" + client.call_id = "call-123" + + payload = client.build_invite_params("v=0\r\n") + + assert payload["sessid"] == "sess-123" + assert payload["sdp"] == "v=0\r\n" + assert payload["User-Agent"].startswith("EVA-TelnyxWebRTC/") + assert payload["dialogParams"] == { + "callID": "call-123", + "caller_id_name": "EVA Caller", + "caller_id_number": "+15550001000", + "destination_number": "ignored-by-ai-assistant", + "audio": True, + "client_state": "state-1", + "custom_headers": [{"name": "X-Eva-Record-Id", "value": "record-1"}], + } + + @pytest.mark.asyncio + async def test_start_requires_aiortc_when_runtime_media_opens(self, monkeypatch) -> None: + async def noop(_: bytes) -> None: + return None + + import eva.assistant.telnyx_server as telnyx_server + + def missing_aiortc() -> tuple[Any, Any, Any, Any]: + raise ValueError("Direct Telnyx WebRTC mode requires aiortc at runtime") + + monkeypatch.setattr(telnyx_server, "_load_aiortc_modules", missing_aiortc) + client = TelnyxWebRTCClient( + config=TelnyxDirectSessionConfig(assistant_id="assistant-123"), + audio_handler=noop, + ) + + with pytest.raises(ValueError, match="requires aiortc"): + await client.start() + + +class TestTelnyxServer: + def test_dynamic_variables_returns_eva_call_id_and_records_conversation_mapping(self) -> None: + server = object.__new__(TelnyxAssistantServer) + server._conversation_map = {} + server._transport = None + server.conversation_id = "record-1" + server._model = "model-1" + + response = server._handle_dynamic_variables_payload( + { + "data": { + "payload": { + "eva_call_id": "eva-call-1", + "telnyx_conversation_id": "telnyx-conv-1", + "call_control_id": "call-control-1", + } + } + } + ) + + assert server._conversation_map == {"eva-call-1": "telnyx-conv-1"} + assert response["dynamic_variables"] == {"eva_call_id": "eva-call-1"} + assert response["conversation"]["metadata"]["eva_record_id"] == "record-1" + + def test_validate_runtime_config_requires_assistant_id_only_for_direct_mode(self) -> None: + server = object.__new__(TelnyxAssistantServer) + server._assistant_id = "" + server._api_key = "" + server.s2s_params = {} + server._webhook_base_url = "" + server._transport_pref = "" + server._create_assistant = False + + with pytest.raises(ValueError) as exc: + server._validate_runtime_config() + + message = str(exc.value) + assert "assistant_id or assistant_agent_id" in message + assert "webhook_base_url" in message + assert "connection_id" in message + assert "to/sip_uri" in message + + def test_validate_runtime_config_does_not_require_call_control_fields(self) -> None: + server = object.__new__(TelnyxAssistantServer) + server._assistant_id = "assistant-123" + server._api_key = "" + server.s2s_params = {} + server._webhook_base_url = "" + server._transport_pref = "" + server._create_assistant = False + + server._validate_runtime_config() + + def test_validate_runtime_config_call_control_mode_requires_credentials(self) -> None: + server = object.__new__(TelnyxAssistantServer) + server._assistant_id = "" + server._api_key = "" + server.s2s_params = {"connection_id": "conn-123", "webhook_base_url": "https://public.example.com"} + server._webhook_base_url = "https://public.example.com" + server._transport_pref = "" + server._create_assistant = False + + with pytest.raises(ValueError) as exc: + server._validate_runtime_config() + + message = str(exc.value) + assert "api_key or telnyx_api_key" in message + assert "from_number or caller_number" in message + assert "to or to_number or destination_number" in message + assert "Call Control mode" in message + + def test_validate_runtime_config_call_control_mode_passes_with_all_fields(self) -> None: + server = object.__new__(TelnyxAssistantServer) + server._assistant_id = "" + server._api_key = "key0123" + server.s2s_params = { + "connection_id": "conn-123", + "webhook_base_url": "https://public.example.com", + "from_number": "+15550001234", + "to": "+15550005678", + } + server._webhook_base_url = "https://public.example.com" + server._transport_pref = "" + server._create_assistant = False + + # Should not raise + server._validate_runtime_config() + + def test_media_streaming_transport_selects_call_control_without_connection_id(self) -> None: + server = object.__new__(TelnyxAssistantServer) + server._transport_pref = "media_streaming" + server.s2s_params = {} + server._webhook_base_url = "" + assert server._use_call_control is True + + def test_validate_runtime_config_call_control_waives_to_when_creating_assistant(self) -> None: + # create_assistant derives the SIP `to` from the freshly-created assistant id, so `to` + # is not required up front; connection_id + from_number are filled by auto-provision. + server = object.__new__(TelnyxAssistantServer) + server._assistant_id = "" + server._api_key = "key0123" + server.s2s_params = { + "connection_id": "conn-123", + "webhook_base_url": "https://public.example.com", + "from_number": "+15550001234", + } + server._webhook_base_url = "https://public.example.com" + server._transport_pref = "media_streaming" + server._create_assistant = True + + # Should not raise even though no `to`/`to_number`/`destination_number` is supplied. + server._validate_runtime_config() + + def test_build_direct_session_config_accepts_aliases_and_metadata(self) -> None: + server = object.__new__(TelnyxAssistantServer) + server._assistant_id = "assistant-123" + server._websocket_host = "wss://rtcdev.telnyx.com" + server._target_version_id = "version-1" + server._target_params = {"locale": "en-US"} + server._user_variables = {"eva_record_id": "record-1"} + server._caller_name = "EVA Caller" + server._caller_number = "+15550001000" + server._destination_number = "ignored" + server._client_state = "state-1" + server._custom_headers = [{"name": "X-Eva-Record-Id", "value": "record-1"}] + + config = server._build_direct_session_config() + + assert config.assistant_id == "assistant-123" + assert config.host == "wss://rtcdev.telnyx.com" + assert config.target_version_id == "version-1" + assert config.target_params == {"locale": "en-US"} + assert config.user_variables == {"eva_record_id": "record-1"} + assert config.custom_headers == [{"name": "X-Eva-Record-Id", "value": "record-1"}] + + def test_tool_stub_maps_agent_tools_without_public_webhook(self) -> None: + server = object.__new__(TelnyxAssistantServer) + server.agent = _make_agent_config() + + tools = server._build_telnyx_tool_definitions() + + assert tools == [ + { + "type": "client_tool", + "name": "get_reservation", + "description": "Look up a reservation by confirmation number", + "parameters": { + "type": "object", + "properties": { + "confirmation_number": { + "type": "string", + "description": "The booking confirmation number", + } + }, + "required": ["confirmation_number"], + }, + } + ] + + @pytest.mark.asyncio + async def test_conversation_event_records_assistant_text(self) -> None: + server = object.__new__(TelnyxAssistantServer) + server.audit_log = AuditLog() + server._fw_log = None + + await server._handle_telnyx_conversation_event( + { + "type": "conversation.item.created", + "item": { + "role": "assistant", + "content": [{"type": "text", "text": "Hello, how can I help?"}], + }, + } + ) + + assert server.audit_log.transcript[-1]["message_type"] == "assistant" + assert server.audit_log.transcript[-1]["value"] == "Hello, how can I help?" + + def test_assistant_audio_active_uses_recent_audio_window(self) -> None: + server = object.__new__(TelnyxAssistantServer) + server._last_assistant_audio_monotonic = time.monotonic() + assert server._is_assistant_audio_active() is True + + server._last_assistant_audio_monotonic = time.monotonic() - 1.0 + assert server._is_assistant_audio_active() is False + + def test_worker_registers_telnyx_framework(self) -> None: + assert _get_server_class("telnyx") is TelnyxAssistantServer + + @pytest.mark.parametrize( + "events_filename", + # The user simulator writes user_simulator_events.jsonl; elevenlabs_events.jsonl + # is the legacy name still accepted by resolve_user_simulator_events_path(). + ["user_simulator_events.jsonl", "elevenlabs_events.jsonl"], + ) + def test_user_event_enrichment_is_idempotent(self, tmp_path, events_filename: str) -> None: + (tmp_path / events_filename).write_text( + json.dumps( + { + "timestamp": 1767225600000, + "type": "user_speech", + "data": {"text": "I need help with my reservation."}, + } + ) + + "\n", + encoding="utf-8", + ) + server = object.__new__(TelnyxAssistantServer) + server.output_dir = tmp_path + server.audit_log = AuditLog() + server._user_events_enriched = False + + server._append_user_events_from_simulator() + server._append_user_events_from_simulator() + + user_entries = [entry for entry in server.audit_log.transcript if entry["message_type"] == "user"] + assert len(user_entries) == 1 + assert user_entries[0]["value"] == "I need help with my reservation." + + @pytest.mark.asyncio + async def test_hangup_active_call_stops_transport(self) -> None: + class FakeTransport: + def __init__(self) -> None: + self.stopped = False + + async def stop(self) -> None: + self.stopped = True + + server = object.__new__(TelnyxAssistantServer) + server._transport = FakeTransport() + + await server._hangup_active_call() + + assert server._transport.stopped is True diff --git a/uv.lock b/uv.lock index 4eae4cdb..a3544e8c 100644 --- a/uv.lock +++ b/uv.lock @@ -129,6 +129,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, ] +[[package]] +name = "aioice" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "ifaddr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/04/df7286233f468e19e9bedff023b6b246182f0b2ccb04ceeb69b2994021c6/aioice-0.10.2.tar.gz", hash = "sha256:bf236c6829ee33c8e540535d31cd5a066b531cb56de2be94c46be76d68b1a806", size = 44307, upload-time = "2025-11-28T15:56:48.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e3/0d23b1f930c17d371ce1ec36ee529f22fd19ebc2a07fe3418e3d1d884ce2/aioice-0.10.2-py3-none-any.whl", hash = "sha256:14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf", size = 24875, upload-time = "2025-11-28T15:56:47.847Z" }, +] + [[package]] name = "aioitertools" version = "0.13.0" @@ -138,6 +151,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, ] +[[package]] +name = "aiortc" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aioice" }, + { name = "av" }, + { name = "cryptography" }, + { name = "google-crc32c" }, + { name = "pyee" }, + { name = "pylibsrtp" }, + { name = "pyopenssl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/42/af1e5755f4cdeb5926ef4aee4bd99d2fbc04b497dfe09f036d359219993e/aiortc-1.15.0.tar.gz", hash = "sha256:ee6c0757ca070cf6d6bee441936d6ede24eef51211bbff6653409c540f72e625", size = 1182039, upload-time = "2026-07-13T19:26:43.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/5f/8435ba02c9278b6cec6f168db92e1d3280dd3af8f2225e20dc7c3be5ab22/aiortc-1.15.0-py3-none-any.whl", hash = "sha256:4e1e54bff31a9c2cb654c7b7edc068085a7df53365e5df24a5cb24168e3f95f7", size = 93678, upload-time = "2026-07-13T19:26:42.241Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -294,6 +325,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/16/fbe8e1e185a45042f7cd3a282def5bb8d95bb69ab9e9ef6a5368aa17e426/audioread-3.1.0-py3-none-any.whl", hash = "sha256:b30d1df6c5d3de5dcef0fb0e256f6ea17bdcf5f979408df0297d8a408e2971b4", size = 23143, upload-time = "2025-10-26T19:44:12.016Z" }, ] +[[package]] +name = "av" +version = "17.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/e3/477fa20578c284abeda08d91b63ee9abaebc93445d8feeb989d3d444bae1/av-17.1.0.tar.gz", hash = "sha256:7f1e71ff621b66253333926f948e00faae11d855b2442133c65128bca64cdeb3", size = 4288546, upload-time = "2026-06-07T05:52:55.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/87/8036b5c781bc3639ea04ef42d4e26da253bd4bd4311d8705b6a1c8824047/av-17.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ad7b4aa011093324b7118245f50ac6db244cfe9900d4072508a5245a2b0d3f41", size = 22460847, upload-time = "2026-06-07T05:52:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/6d/af/dfdf6fc7b17814b50d0aa9e7a7e37b87be91be3890f44b0d525433cd1fd1/av-17.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:43ebbe977f19a7f2d2bd1a4e119675a0b15e05852cf7309846b6ab922ba7ffe9", size = 18159115, upload-time = "2026-06-07T05:52:06.64Z" }, + { url = "https://files.pythonhosted.org/packages/ad/13/64f6c466471cea225b8b2f4cdc51a571f8a286984b55a08d169b932fda5d/av-17.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6a20658ec7d96a70e14b1196eff00b7cdd8831ac3b99868e16b8ba8b24090847", size = 33224427, upload-time = "2026-06-07T05:52:09.165Z" }, + { url = "https://files.pythonhosted.org/packages/77/43/96b35170bf2e64e00a41748c6400ff73232dc0fc62ded283679fb07c7fe0/av-17.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f9a65d1f48b818323fb411e80358f89d77dec340b01d27c6b2dfbb9cbf4b779f", size = 35370183, upload-time = "2026-06-07T05:52:11.959Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b3/8e8b4b6498731bfbd88e8399a756543f8088f1bd33d08eab678b5aebe728/av-17.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:58f7593726437cda5bd19793027e027768450b5c4a594777bf487798a33db702", size = 24459265, upload-time = "2026-06-07T05:52:14.66Z" }, + { url = "https://files.pythonhosted.org/packages/14/ac/ceb84b7553db21f1143d817245c560d9267168e1e58b1a8eeae2b62c4d04/av-17.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bbab058bd965309f39962e53caac8126987c68c0be094fc4f9427e5615b0218f", size = 34283709, upload-time = "2026-06-07T05:52:17.389Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/4115fd84148c9a1cf365096694be6ac882fd3cd3cdb7a2f35e71fecf1631/av-17.1.0-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9514cfda85180554c430695282faf4be3ffdf95775d8519733821244eecb58e0", size = 25397573, upload-time = "2026-06-07T05:52:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/92e52d5ed0e0b84d9d93e52b4338c2713d8a44082b8696e6516fdae7c4e4/av-17.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e1c90f85cd7431ede95b11e8e711571a896ebea433f298849c2c0f1594c8d86e", size = 36451495, upload-time = "2026-06-07T05:52:22.581Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/53a7cd34adb6a971d7e6d99663e74db286966c9db8afdca17472fdf0f98e/av-17.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:5df5c1172ef1cf65a1529d612f7da7798ce2cf82c1ff7212466b538a6cc7214c", size = 28036393, upload-time = "2026-06-07T05:52:25.657Z" }, + { url = "https://files.pythonhosted.org/packages/66/47/cd9ae0edf2206351c1251bb94b5ec58728e42c5f6ee16c03c412f3a1bb3e/av-17.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:ee98534242a74da847af78624779ac5a3177dc7c69f956a4da9e6f0fdb37d7f6", size = 21174601, upload-time = "2026-06-07T05:52:28.077Z" }, +] + [[package]] name = "azure-cognitiveservices-speech" version = "1.48.2" @@ -692,6 +741,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -724,6 +782,8 @@ source = { editable = "." } dependencies = [ { name = "aioboto3" }, { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "aiortc" }, { name = "anthropic" }, { name = "assemblyai" }, { name = "azure-cognitiveservices-speech" }, @@ -751,6 +811,7 @@ dependencies = [ { name = "pyyaml" }, { name = "regex" }, { name = "setuptools" }, + { name = "soxr" }, { name = "statsmodels" }, { name = "structlog" }, { name = "tqdm" }, @@ -781,6 +842,8 @@ dev = [ requires-dist = [ { name = "aioboto3", specifier = ">=12.0.0" }, { name = "aiofiles", specifier = ">=23.0" }, + { name = "aiohttp", specifier = ">=3.8.0" }, + { name = "aiortc", specifier = ">=1.6.0" }, { name = "anthropic", specifier = ">=0.83.0" }, { name = "assemblyai", specifier = ">=0.17.0" }, { name = "audioread", marker = "extra == 'apps'", specifier = ">=3.1" }, @@ -819,6 +882,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "setuptools", specifier = ">=65.0.0" }, { name = "soundfile", marker = "extra == 'apps'", specifier = ">=0.13" }, + { name = "soxr", specifier = ">=0.3.0" }, { name = "statsmodels", specifier = ">=0.14.6" }, { name = "streamlit", marker = "extra == 'apps'", specifier = ">=1.56.0" }, { name = "streamlit-diff-viewer", marker = "extra == 'apps'", specifier = ">=0.0.2" }, @@ -1081,6 +1145,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/0a/292131ec789045f1c646f3ced623b457dc5f52ce8c681f66f2def5724a76/google_cloud_texttospeech-2.34.0-py3-none-any.whl", hash = "sha256:0929c06e91a5b8309db7b93d79e7e1e3e7751d21c5da72fa71843c041fc29ecb", size = 196009, upload-time = "2026-01-15T13:02:44.617Z" }, ] +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, +] + [[package]] name = "google-genai" version = "1.69.0" @@ -1239,14 +1328,14 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "filelock", marker = "python_full_version < '3.13'" }, - { name = "fsspec", marker = "python_full_version < '3.13'" }, - { name = "hf-xet", marker = "(python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'arm64') or (python_full_version < '3.13' and platform_machine == 'x86_64')" }, - { name = "packaging", marker = "python_full_version < '3.13'" }, - { name = "pyyaml", marker = "python_full_version < '3.13'" }, - { name = "requests", marker = "python_full_version < '3.13'" }, - { name = "tqdm", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" } wheels = [ @@ -1261,15 +1350,15 @@ resolution-markers = [ "python_full_version >= '3.13'", ] dependencies = [ - { name = "filelock", marker = "python_full_version >= '3.13'" }, - { name = "fsspec", marker = "python_full_version >= '3.13'" }, - { name = "hf-xet", marker = "(python_full_version >= '3.13' and platform_machine == 'AMD64') or (python_full_version >= '3.13' and platform_machine == 'aarch64') or (python_full_version >= '3.13' and platform_machine == 'amd64') or (python_full_version >= '3.13' and platform_machine == 'arm64') or (python_full_version >= '3.13' and platform_machine == 'x86_64')" }, - { name = "httpx", marker = "python_full_version >= '3.13'" }, - { name = "packaging", marker = "python_full_version >= '3.13'" }, - { name = "pyyaml", marker = "python_full_version >= '3.13'" }, - { name = "tqdm", marker = "python_full_version >= '3.13'" }, - { name = "typer", marker = "python_full_version >= '3.13'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.13'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/76/b5efb3033d8499b17f9386beaf60f64c461798e1ee16d10bc9c0077beba5/huggingface_hub-1.5.0.tar.gz", hash = "sha256:f281838db29265880fb543de7a23b0f81d3504675de82044307ea3c6c62f799d", size = 695872, upload-time = "2026-02-26T15:35:32.745Z" } wheels = [ @@ -1294,6 +1383,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "ifaddr" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, +] + [[package]] name = "importlib-metadata" version = "8.7.1" @@ -1612,7 +1710,7 @@ name = "markdown-it-py" version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.13'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ @@ -2523,6 +2621,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -2532,6 +2642,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pylibsrtp" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/a6/6e532bec974aaecbf9fe4e12538489fb1c28456e65088a50f305aeab9f89/pylibsrtp-1.0.0.tar.gz", hash = "sha256:b39dff075b263a8ded5377f2490c60d2af452c9f06c4d061c7a2b640612b34d4", size = 10858, upload-time = "2025-10-13T16:12:31.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/af/89e61a62fa3567f1b7883feb4d19e19564066c2fcd41c37e08d317b51881/pylibsrtp-1.0.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:822c30ea9e759b333dc1f56ceac778707c51546e97eb874de98d7d378c000122", size = 1865017, upload-time = "2025-10-13T16:12:15.62Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0e/8d215484a9877adcf2459a8b28165fc89668b034565277fd55d666edd247/pylibsrtp-1.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:aaad74e5c8cbc1c32056c3767fea494c1e62b3aea2c908eda2a1051389fdad76", size = 2182739, upload-time = "2025-10-13T16:12:17.121Z" }, + { url = "https://files.pythonhosted.org/packages/57/3f/76a841978877ae13eac0d4af412c13bbd5d83b3df2c1f5f2175f2e0f68e5/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9209b86e662ebbd17c8a9e8549ba57eca92a3e87fb5ba8c0e27b8c43cd08a767", size = 2732922, upload-time = "2025-10-13T16:12:18.348Z" }, + { url = "https://files.pythonhosted.org/packages/0e/14/cf5d2a98a66fdfe258f6b036cda570f704a644fa861d7883a34bc359501e/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc", size = 2434534, upload-time = "2025-10-13T16:12:20.074Z" }, + { url = "https://files.pythonhosted.org/packages/bd/08/a3f6e86c04562f7dce6717cd2206a0f84ca85c5e38121d998e0e330194c3/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:81fb8879c2e522021a7cbd3f4bda1b37c192e1af939dfda3ff95b4723b329663", size = 2345818, upload-time = "2025-10-13T16:12:21.439Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d5/130c2b5b4b51df5631684069c6f0a6761c59d096a33d21503ac207cf0e47/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4ddb562e443cf2e557ea2dfaeef0d7e6b90e96dd38eb079b4ab2c8e34a79f50b", size = 2774490, upload-time = "2025-10-13T16:12:22.659Z" }, + { url = "https://files.pythonhosted.org/packages/91/e3/715a453bfee3bea92a243888ad359094a7727cc6d393f21281320fe7798c/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f02e616c9dfab2b03b32d8cc7b748f9d91814c0211086f987629a60f05f6e2cc", size = 2372603, upload-time = "2025-10-13T16:12:24.036Z" }, + { url = "https://files.pythonhosted.org/packages/e3/56/52fa74294254e1f53a4ff170ee2006e57886cf4bb3db46a02b4f09e1d99f/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c134fa09e7b80a5b7fed626230c5bc257fd771bd6978e754343e7a61d96bc7e6", size = 2451269, upload-time = "2025-10-13T16:12:25.475Z" }, + { url = "https://files.pythonhosted.org/packages/1e/51/2e9b34f484cbdd3bac999bf1f48b696d7389433e900639089e8fc4e0da0d/pylibsrtp-1.0.0-cp310-abi3-win32.whl", hash = "sha256:bae377c3b402b17b9bbfbfe2534c2edba17aa13bea4c64ce440caacbe0858b55", size = 1247503, upload-time = "2025-10-13T16:12:27.39Z" }, + { url = "https://files.pythonhosted.org/packages/c3/70/43db21af194580aba2d9a6d4c7bd8c1a6e887fa52cd810b88f89096ecad2/pylibsrtp-1.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:8d6527c4a78a39a8d397f8862a8b7cdad4701ee866faf9de4ab8c70be61fd34d", size = 1601659, upload-time = "2025-10-13T16:12:29.037Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ec/6e02b2561d056ea5b33046e3cad21238e6a9097b97d6ccc0fbe52b50c858/pylibsrtp-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:2696bdb2180d53ac55d0eb7b58048a2aa30cd4836dd2ca683669889137a94d2a", size = 1159246, upload-time = "2025-10-13T16:12:30.285Z" }, +] + [[package]] name = "pyloudnorm" version = "0.2.0" @@ -2545,6 +2677,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/b6/65a49a05614b2548edbba3aab118f2ebe7441dfd778accdcdce9f6567f20/pyloudnorm-0.2.0-py3-none-any.whl", hash = "sha256:9bb69afb904f59d007a7f9ba3d75d16fb8aeef35c44d6df822a9f192d69cf13f", size = 10879, upload-time = "2026-01-04T11:43:34.534Z" }, ] +[[package]] +name = "pyopenssl" +version = "26.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -2856,8 +3001,8 @@ name = "rich" version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", marker = "python_full_version >= '3.13'" }, - { name = "pygments", marker = "python_full_version >= '3.13'" }, + { name = "markdown-it-py" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ @@ -3159,8 +3304,8 @@ name = "standard-aifc" version = "3.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, - { name = "standard-chunk", marker = "python_full_version >= '3.13'" }, + { name = "audioop-lts" }, + { name = "standard-chunk" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" } wheels = [ @@ -3181,7 +3326,7 @@ name = "standard-sunau" version = "3.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, + { name = "audioop-lts" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/e3/ce8d38cb2d70e05ffeddc28bb09bad77cfef979eb0a299c9117f7ed4e6a9/standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908", size = 9368, upload-time = "2024-10-30T16:01:41.626Z" } wheels = [ @@ -3462,10 +3607,10 @@ name = "typer" version = "0.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc", marker = "python_full_version >= '3.13'" }, - { name = "click", marker = "python_full_version >= '3.13'" }, - { name = "rich", marker = "python_full_version >= '3.13'" }, - { name = "shellingham", marker = "python_full_version >= '3.13'" }, + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } wheels = [