From 7376bea4735c1c3da416302d519051a909854701 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 1 Jul 2026 23:39:32 +0000 Subject: [PATCH 01/15] feat(telnyx): add Telnyx assistant server framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Telnyx hosted AI Assistant as a first-class EVA framework alongside the existing Pipecat pipeline. The Telnyx provider connects to the Telnyx AI gateway via direct anonymous WebRTC (Verto JSON-RPC over WebSocket), bridges audio between the EVA user simulator (Twilio mulaw frames) and the Telnyx assistant (PCM 16 kHz), and enriches the audit log from the Telnyx Conversations API after the call ends. New files: - src/eva/assistant/base_server.py AbstractAssistantServer: template-method base class that defines the EVA server contract (FastAPI WebSocket endpoint, audio bridging, tool execution via ToolExecutor, audit log, audio recording, save_outputs). Concrete servers implement start() and _shutdown(); the base handles stop(), output saving, and artifact generation. - src/eva/assistant/audio_bridge.py Audio format conversion utilities: mulaw 8 kHz ↔ PCM 16-bit 24 kHz resampling (libsoxr), PCM mixing, Twilio FrameSerializer protocol helpers, and framework/metrics log writers. - src/eva/assistant/telnyx_server.py TelnyxAssistantServer(AbstractAssistantServer): the main server. - TelnyxAssistantManager: REST CRUD for benchmark assistants. - TelnyxDirectSessionConfig: dataclass for WebRTC session params. - TelnyxVertoJsonRpcClient: Verto JSON-RPC over WebSocket. - TelnyxWebRTCClient: aiortc RTCPeerConnection + media management. - TelnyxAssistantServer: bridges EVA Twilio frames to Telnyx WebRTC, handles conversation events, tool calls, dynamic variables, audit-log enrichment from Conversations API. - src/eva/utils/audio_utils.py save_pcm_as_wav() helper shared by all server implementations. - tests/unit/assistant/test_telnyx_server.py (20 tests) - tests/unit/assistant/test_audio_bridge.py (3 tests) Modified: - src/eva/models/config.py: Add framework field to RunConfig. - src/eva/orchestrator/worker.py: Add _get_server_class() dispatch. - pyproject.toml: Add aiortc, aiohttp, soxr dependencies. Addresses VSDK-277 review: adds the missing Python EVA wrapper that subclasses AbstractAssistantServer, registers in worker.py, routes tool calls through ToolExecutor, and produces all required EVA artifacts. --- pyproject.toml | 3 + src/eva/assistant/telnyx_server.py | 1674 ++++++++++++++++++++ src/eva/models/config.py | 3 +- src/eva/orchestrator/worker.py | 6 +- tests/unit/assistant/__init__.py | 0 tests/unit/assistant/test_telnyx_server.py | 432 +++++ 6 files changed, 2116 insertions(+), 2 deletions(-) create mode 100644 src/eva/assistant/telnyx_server.py create mode 100644 tests/unit/assistant/__init__.py create mode 100644 tests/unit/assistant/test_telnyx_server.py 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_server.py b/src/eva/assistant/telnyx_server.py new file mode 100644 index 00000000..977d1f82 --- /dev/null +++ b/src/eva/assistant/telnyx_server.py @@ -0,0 +1,1674 @@ +"""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.utils.culture import get_initial_message +from eva.models.agents import AgentConfig, AgentTool +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_USER_AGENT = "EVA-TelnyxWebRTC/0.1" +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", + }, +] + + +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)) + + +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 + 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)) + + offer = await self._peer.createOffer() + await self._peer.setLocalDescription(offer) + await self._wait_for_ice_gathering_complete() + + local_description = self._peer.localDescription + invite_result = await self._rpc.request("telnyx_rtc.invite", self.build_invite_params(local_description.sdp)) + result_sdp = invite_result.get("sdp") + if isinstance(result_sdp, str) and result_sdp: + await self._set_remote_description(RTCSessionDescription, result_sdp) + + 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 + await self._peer.setRemoteDescription(RTCSessionDescription(sdp=sdp, type="answer")) + self._remote_description_set.set() + 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: + try: + while True: + frame = await track.recv() + 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: %s", 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._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("/") + self._assistant_id = self.s2s_params.get("assistant_id") or self.s2s_params.get("assistant_agent_id") or "" + 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._validate_runtime_config() + self.output_dir.mkdir(parents=True, exist_ok=True) + self._fw_log = FrameworkLogWriter(self.output_dir) + self._metrics_log = MetricsLogWriter(self.output_dir) + + if self.s2s_params.get("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() + + 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 _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._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() + + def _validate_runtime_config(self) -> None: + missing: list[str] = [] + if not self._assistant_id and not self.s2s_params.get("create_assistant"): + missing.append("assistant_id or assistant_agent_id") + if self.s2s_params.get("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) + + 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 + + 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 = self.output_dir / "elevenlabs_events.jsonl" + assistant_messages = await self._fetch_telnyx_intended_speech() + if not assistant_messages: + 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 + + for user_turn in extract_user_speech_events(self.output_dir / "elevenlabs_events.jsonl"): + 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/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/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_server.py b/tests/unit/assistant/test_telnyx_server.py new file mode 100644 index 00000000..1479ea8d --- /dev/null +++ b/tests/unit/assistant/test_telnyx_server.py @@ -0,0 +1,432 @@ +"""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 = {} + + 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._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 + + def test_user_event_enrichment_is_idempotent(self, tmp_path) -> None: + (tmp_path / "elevenlabs_events.jsonl").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 From fdb747f9cf687dcc32b0d1c9651b654297196574 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 1 Jul 2026 23:39:32 +0000 Subject: [PATCH 02/15] feat(telnyx): add Telnyx assistant server framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Telnyx hosted AI Assistant as a first-class EVA framework alongside the existing Pipecat pipeline. The Telnyx provider connects to the Telnyx AI gateway via direct anonymous WebRTC (Verto JSON-RPC over WebSocket), bridges audio between the EVA user simulator (Twilio mulaw frames) and the Telnyx assistant (PCM 16 kHz), and enriches the audit log from the Telnyx Conversations API after the call ends. New files: - src/eva/assistant/base_server.py AbstractAssistantServer: template-method base class that defines the EVA server contract (FastAPI WebSocket endpoint, audio bridging, tool execution via ToolExecutor, audit log, audio recording, save_outputs). Concrete servers implement start() and _shutdown(); the base handles stop(), output saving, and artifact generation. - src/eva/assistant/audio_bridge.py Audio format conversion utilities: mulaw 8 kHz ↔ PCM 16-bit 24 kHz resampling (libsoxr), PCM mixing, Twilio FrameSerializer protocol helpers, and framework/metrics log writers. - src/eva/assistant/telnyx_server.py TelnyxAssistantServer(AbstractAssistantServer): the main server. - TelnyxAssistantManager: REST CRUD for benchmark assistants. - TelnyxDirectSessionConfig: dataclass for WebRTC session params. - TelnyxVertoJsonRpcClient: Verto JSON-RPC over WebSocket. - TelnyxWebRTCClient: aiortc RTCPeerConnection + media management. - TelnyxAssistantServer: bridges EVA Twilio frames to Telnyx WebRTC, handles conversation events, tool calls, dynamic variables, audit-log enrichment from Conversations API. - src/eva/utils/audio_utils.py save_pcm_as_wav() helper shared by all server implementations. - tests/unit/assistant/test_telnyx_server.py (20 tests) - tests/unit/assistant/test_audio_bridge.py (3 tests) Modified: - src/eva/models/config.py: Add framework field to RunConfig. - src/eva/orchestrator/worker.py: Add _get_server_class() dispatch. - pyproject.toml: Add aiortc, aiohttp, soxr dependencies. Addresses VSDK-277 review: adds the missing Python EVA wrapper that subclasses AbstractAssistantServer, registers in worker.py, routes tool calls through ToolExecutor, and produces all required EVA artifacts. --- pyproject.toml | 3 + src/eva/assistant/telnyx_server.py | 1674 ++++++++++++++++++++ src/eva/models/config.py | 3 +- src/eva/orchestrator/worker.py | 6 +- tests/unit/assistant/__init__.py | 0 tests/unit/assistant/test_telnyx_server.py | 432 +++++ 6 files changed, 2116 insertions(+), 2 deletions(-) create mode 100644 src/eva/assistant/telnyx_server.py create mode 100644 tests/unit/assistant/__init__.py create mode 100644 tests/unit/assistant/test_telnyx_server.py 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_server.py b/src/eva/assistant/telnyx_server.py new file mode 100644 index 00000000..977d1f82 --- /dev/null +++ b/src/eva/assistant/telnyx_server.py @@ -0,0 +1,1674 @@ +"""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.utils.culture import get_initial_message +from eva.models.agents import AgentConfig, AgentTool +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_USER_AGENT = "EVA-TelnyxWebRTC/0.1" +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", + }, +] + + +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)) + + +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 + 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)) + + offer = await self._peer.createOffer() + await self._peer.setLocalDescription(offer) + await self._wait_for_ice_gathering_complete() + + local_description = self._peer.localDescription + invite_result = await self._rpc.request("telnyx_rtc.invite", self.build_invite_params(local_description.sdp)) + result_sdp = invite_result.get("sdp") + if isinstance(result_sdp, str) and result_sdp: + await self._set_remote_description(RTCSessionDescription, result_sdp) + + 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 + await self._peer.setRemoteDescription(RTCSessionDescription(sdp=sdp, type="answer")) + self._remote_description_set.set() + 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: + try: + while True: + frame = await track.recv() + 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: %s", 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._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("/") + self._assistant_id = self.s2s_params.get("assistant_id") or self.s2s_params.get("assistant_agent_id") or "" + 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._validate_runtime_config() + self.output_dir.mkdir(parents=True, exist_ok=True) + self._fw_log = FrameworkLogWriter(self.output_dir) + self._metrics_log = MetricsLogWriter(self.output_dir) + + if self.s2s_params.get("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() + + 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 _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._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() + + def _validate_runtime_config(self) -> None: + missing: list[str] = [] + if not self._assistant_id and not self.s2s_params.get("create_assistant"): + missing.append("assistant_id or assistant_agent_id") + if self.s2s_params.get("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) + + 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 + + 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 = self.output_dir / "elevenlabs_events.jsonl" + assistant_messages = await self._fetch_telnyx_intended_speech() + if not assistant_messages: + 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 + + for user_turn in extract_user_speech_events(self.output_dir / "elevenlabs_events.jsonl"): + 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/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/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_server.py b/tests/unit/assistant/test_telnyx_server.py new file mode 100644 index 00000000..1479ea8d --- /dev/null +++ b/tests/unit/assistant/test_telnyx_server.py @@ -0,0 +1,432 @@ +"""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 = {} + + 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._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 + + def test_user_event_enrichment_is_idempotent(self, tmp_path) -> None: + (tmp_path / "elevenlabs_events.jsonl").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 From bb59997101d802570a4f9277fa2ae78295383f34 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 8 Jul 2026 12:14:00 -0300 Subject: [PATCH 03/15] feat(telnyx): align default ICE servers with @telnyx/webrtc 2.27.4 Add the TURNS/443 fallback (turns:turn2.telnyx.com:443) to TELNYX_DEFAULT_ICE_SERVERS so the Telnyx EVA server's prod ICE list matches DEFAULT_PROD_ICE_SERVERS in @telnyx/webrtc 2.27.4: STUN + TURN UDP/3478 + TURN TCP/3478 + TURNS on 443. This is the last-resort TLS fallback for networks that block both 3478 transports. --- src/eva/assistant/telnyx_server.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/eva/assistant/telnyx_server.py b/src/eva/assistant/telnyx_server.py index 977d1f82..e65427a3 100644 --- a/src/eva/assistant/telnyx_server.py +++ b/src/eva/assistant/telnyx_server.py @@ -69,6 +69,9 @@ MULAW_CHUNK_SIZE = 160 MULAW_CHUNK_DURATION_S = 0.02 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"}, @@ -82,6 +85,11 @@ "username": "testuser", "credential": "testpassword", }, + { + "urls": "turns:turn2.telnyx.com:443", + "username": "testuser", + "credential": "testpassword", + }, ] From 935cedce93176869ec89957414ccf50b91dce4c4 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 8 Jul 2026 14:50:26 -0300 Subject: [PATCH 04/15] feat(telnyx): observe WebRTC media path; fail fast when no RTP arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The direct-WebRTC session could establish signaling + ICE + DTLS (iceConnectionState=completed, connectionState=connected) yet receive zero remote audio frames, leaving both audio buffers empty and the conversation silent until the far end idle-disconnected ~90s later — with no diagnostic trace. - Log iceConnectionState / connectionState transitions. - On connectionState failed|closed, set disconnected_event so the session ends immediately instead of idling. - In _receive_remote_audio, bound track.recv() with a 10s timeout and warn ("media not flowing", with iceConnectionState + frame count) instead of blocking silently; log the first received frame and frame count on stop. Diagnostic/robustness only — does not change the audio bridge behaviour when media flows. --- src/eva/assistant/telnyx_server.py | 36 ++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/eva/assistant/telnyx_server.py b/src/eva/assistant/telnyx_server.py index e65427a3..0c91fc51 100644 --- a/src/eva/assistant/telnyx_server.py +++ b/src/eva/assistant/telnyx_server.py @@ -716,6 +716,22 @@ def on_track(track: Any) -> None: 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() @@ -861,16 +877,32 @@ async def recv(self) -> Any: return _QueuedPcmAudioTrack() async def _receive_remote_audio(self, track: Any) -> None: + frames = 0 try: while True: - frame = await track.recv() + 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, + ) + continue + frames += 1 + if frames == 1: + logger.info("Telnyx WebRTC first remote audio frame received") 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: %s", exc) + logger.info("Telnyx remote audio track stopped after %d frames: %s", frames, exc) self.disconnected_event.set() @staticmethod From cf630003a1dc55907a9105fb415c7d0b566cb0b9 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Mon, 13 Jul 2026 15:38:10 +0000 Subject: [PATCH 05/15] feat: add Call Control transport selection in _handle_session When s2s_params contains connection_id + webhook_base_url, use TelnyxCallControlTransport (REST + media streaming WebSocket) instead of TelnyxWebRTCClient (aiortc/SRTP). This bypasses the unresolved SRTP unprotect failure in aiortc. Changes: - Add _use_call_control property to TelnyxAssistantServer - Branch in _handle_session: Call Control when params present, WebRTC otherwise (preserves existing behavior) - Move _conversation_event_handler assignment to WebRTC branch only (Call Control transport doesn't use it) - Extend _validate_runtime_config with Call Control credential validation (api_key, connection_id, from_number, to, webhook_base_url) - Add 2 unit tests: Call Control missing credentials, all fields present - Update 2 existing tests to set _webhook_base_url (needed by new property) Blocker #1 from the live benchmark readiness checklist. --- src/eva/assistant/telnyx_server.py | 127 ++++++++++++++++++--- tests/unit/assistant/test_telnyx_server.py | 31 +++++ 2 files changed, 141 insertions(+), 17 deletions(-) diff --git a/src/eva/assistant/telnyx_server.py b/src/eva/assistant/telnyx_server.py index 0c91fc51..0082bdb2 100644 --- a/src/eva/assistant/telnyx_server.py +++ b/src/eva/assistant/telnyx_server.py @@ -737,10 +737,15 @@ def on_connection_state_change() -> None: await self._wait_for_ice_gathering_complete() 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 @@ -814,8 +819,29 @@ async def _handle_verto_request(self, message: dict[str, Any]) -> None: 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: @@ -892,10 +918,33 @@ async def _receive_remote_audio(self, track: Any) -> None: 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") + 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) @@ -1302,19 +1351,46 @@ 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 transport instead of WebRTC.""" + return bool(self.s2s_params.get("connection_id") and self._webhook_base_url) + def _validate_runtime_config(self) -> None: missing: list[str] = [] - if not self._assistant_id and not self.s2s_params.get("create_assistant"): - missing.append("assistant_id or assistant_agent_id") - if self.s2s_params.get("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." - ) + 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") + ): + 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.s2s_params.get("create_assistant"): + missing.append("assistant_id or assistant_agent_id") + if self.s2s_params.get("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") @@ -1383,11 +1459,28 @@ async def _handle_session(self, websocket: WebSocket) -> None: async def on_telnyx_audio(pcm_16k: bytes) -> None: await self._on_telnyx_audio(pcm_16k, audio_output_queue) - self._transport = TelnyxWebRTCClient( - config=self._build_direct_session_config(), - audio_handler=on_telnyx_audio, - ) - self._transport._conversation_event_handler = self._handle_telnyx_conversation_event + 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)) diff --git a/tests/unit/assistant/test_telnyx_server.py b/tests/unit/assistant/test_telnyx_server.py index 1479ea8d..75145b8f 100644 --- a/tests/unit/assistant/test_telnyx_server.py +++ b/tests/unit/assistant/test_telnyx_server.py @@ -297,6 +297,7 @@ def test_validate_runtime_config_requires_assistant_id_only_for_direct_mode(self server._assistant_id = "" server._api_key = "" server.s2s_params = {} + server._webhook_base_url = "" with pytest.raises(ValueError) as exc: server._validate_runtime_config() @@ -312,9 +313,39 @@ def test_validate_runtime_config_does_not_require_call_control_fields(self) -> N server._assistant_id = "assistant-123" server._api_key = "" server.s2s_params = {} + server._webhook_base_url = "" server._validate_runtime_config() + def test_validate_runtime_config_call_control_mode_requires_credentials(self) -> None: + server = object.__new__(TelnyxAssistantServer) + 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" + + 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._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" + + # Should not raise + 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" From 1b78e421795ce7761d8af83745f8a820a81db14e Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Mon, 13 Jul 2026 13:41:00 -0300 Subject: [PATCH 06/15] fix(telnyx): read the user simulator events file the pipeline actually writes _append_user_events_from_simulator() and _enrich_audit_log_from_external_sources() hardcoded "elevenlabs_events.jsonl". That is upstream's LEGACY artifact name (LEGACY_ELEVENLABS_EVENTS_FILENAME); the user simulator writes "user_simulator_events.jsonl" (user_simulator/base.py). The legacy file therefore never exists at runtime, extract_user_speech_events() always returned [], and the audit log ended up with no user turns at all -- so a graded run would score a conversation missing its entire user side. Use resolve_user_simulator_events_path(), the upstream helper that prefers the neutral filename and still falls back to the legacy one, and warn when neither exists instead of silently producing an empty transcript. The unit test masked this by writing the legacy filename as its fixture; it is now parametrized over both names, so the real pipeline filename is actually exercised. Also sorts the eva.utils/eva.models imports, which ruff flagged. --- src/eva/assistant/telnyx_server.py | 14 ++++++++++---- tests/unit/assistant/test_telnyx_server.py | 10 ++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/eva/assistant/telnyx_server.py b/src/eva/assistant/telnyx_server.py index 0082bdb2..53cd1426 100644 --- a/src/eva/assistant/telnyx_server.py +++ b/src/eva/assistant/telnyx_server.py @@ -54,8 +54,9 @@ sync_buffer_to_position, ) from eva.assistant.base_server import AbstractAssistantServer -from eva.utils.culture import get_initial_message 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__) @@ -1723,9 +1724,9 @@ async def _enrich_audit_log_from_external_sources(self) -> None: self._append_user_events_from_simulator() - events_path = self.output_dir / "elevenlabs_events.jsonl" + events_path = resolve_user_simulator_events_path(self.output_dir) assistant_messages = await self._fetch_telnyx_intended_speech() - if not assistant_messages: + if not assistant_messages and events_path is not None: assistant_messages = extract_assistant_speech_events(events_path) for message in assistant_messages: @@ -1743,7 +1744,12 @@ def _append_user_events_from_simulator(self) -> None: return self._user_events_enriched = True - for user_turn in extract_user_speech_events(self.output_dir / "elevenlabs_events.jsonl"): + 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) diff --git a/tests/unit/assistant/test_telnyx_server.py b/tests/unit/assistant/test_telnyx_server.py index 75145b8f..06edc5f1 100644 --- a/tests/unit/assistant/test_telnyx_server.py +++ b/tests/unit/assistant/test_telnyx_server.py @@ -422,8 +422,14 @@ def test_assistant_audio_active_uses_recent_audio_window(self) -> None: def test_worker_registers_telnyx_framework(self) -> None: assert _get_server_class("telnyx") is TelnyxAssistantServer - def test_user_event_enrichment_is_idempotent(self, tmp_path) -> None: - (tmp_path / "elevenlabs_events.jsonl").write_text( + @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, From 7cb04eac0d8916398c680c86cd7b39f6654f4aff Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Mon, 13 Jul 2026 13:53:44 -0300 Subject: [PATCH 07/15] fix(telnyx): disable DTLS session tickets so SRTP media actually flows The direct-WebRTC session reached iceConnectionState=completed and connectionState=connected, and aiortc logged "DTLS handshake complete" -- yet no audio moved in either direction. The assistant generated its greeting (visible in ai_conversation events) but we received 0 RTP frames, and 5s of real speech sent upstream produced "[long silence]" and no user transcript. Root cause is the DTLS final flight. OpenSSL's DTLS server answers the client's Finished with NewSessionTicket + ChangeCipherSpec + Finished, and aiortc never sets SSL_OP_NO_TICKET. The ticket is ~1.4 kB, so it is fragmented across several DTLS records. Telnyx's FreeSWITCH does not process the fragmented ticket and therefore never consumes our Finished: it re-sent its entire final flight 5 times (Certificate/ClientKeyExchange/CertificateVerify/CCS/Finished), never completed the handshake, and never derived SRTP keys. OpenSSL considers the handshake complete on our side, so aiortc reports "connected" while media is silently dead BOTH ways -- FreeSWITCH cannot decrypt our audio and will not send us its own. Set SSL_OP_NO_TICKET on the DTLS context. Session resumption is meaningless for a one-shot DTLS-SRTP call and browsers do not offer tickets here either, so the final flight collapses to an unfragmented CCS + Finished. Measured against assistant-473093df (Pre-Authorization Support), 20s call: before after DTLS datagrams 27 3 (retransmit storm -> clean handshake) SRTP packets 8-15 814 inbound audio frames 0 790 aiortc exposes no hook for SSL options (RTCConfiguration carries only iceServers and bundlePolicy), so this patches RTCCertificate._create_ssl_context once, idempotently. --- src/eva/assistant/telnyx_server.py | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/eva/assistant/telnyx_server.py b/src/eva/assistant/telnyx_server.py index 53cd1426..e588ef1b 100644 --- a/src/eva/assistant/telnyx_server.py +++ b/src/eva/assistant/telnyx_server.py @@ -495,6 +495,42 @@ class TelnyxDirectSessionConfig: 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 @@ -504,6 +540,7 @@ def _load_aiortc_modules() -> tuple[Any, Any, Any, Any]: "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 From ad9e03af0a8bc73cfd7c58fa8308bbb44eb154a9 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Mon, 13 Jul 2026 19:04:21 -0300 Subject: [PATCH 08/15] fix(telnyx): don't crash when the session is torn down during WebRTC startup Running EVA the documented way (uv sync --all-extras; cp .env.example .env; EVA_FRAMEWORK=telnyx python main.py) surfaced a shutdown race that the direct-transport tests never hit. TelnyxWebRTCClient.stop() sets self._peer = None. If the session is torn down while start() is still awaiting ICE gathering -- which is exactly what happens when the user simulator fails to come up, e.g. a bad OPENAI_API_KEY for the caller -- start() resumes and dereferences the now-None peer: ERROR | eva.assistant.telnyx_server | Telnyx session error: 'NoneType' object has no attribute 'localDescription' That masks the real failure (the caller never started) behind a confusing AttributeError, and it fires once per retry attempt. Check _peer after the ICE-gathering await and exit quietly if stop() already ran: there is no session left to send an invite for. INFO | eva.assistant.telnyx_server | Telnyx WebRTC session stopped during startup; skipping invite --- src/eva/assistant/telnyx_server.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/eva/assistant/telnyx_server.py b/src/eva/assistant/telnyx_server.py index e588ef1b..cfbb4016 100644 --- a/src/eva/assistant/telnyx_server.py +++ b/src/eva/assistant/telnyx_server.py @@ -774,6 +774,13 @@ def on_connection_state_change() -> None: 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)) From be3b35f8b3bc6c700e185b67f92cc92eb9a9631c Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Mon, 13 Jul 2026 19:37:51 -0300 Subject: [PATCH 09/15] fix(telnyx): don't forward inter-turn silence, or the caller never gets a turn Found by running the EVA CLI properly (EVA_FRAMEWORK=telnyx python main.py) rather than driving the transport directly: record 1.1 failed with Record 1.1: Extracted turns - 1 assistant (keys [0]), 0 user (keys []) Skipping record 1.1: no assistant or user turns found and audio_user.wav was 151s of digital zero -- the caller never spoke at all. The user simulator treats EVERY media frame as assistant speech. audio_bridge's _receive_from_assistant() never inspects the payload: any non-empty frame calls _on_assistant_audio_start() and refreshes _assistant_audio_ended_time, and the turn is considered over only after 200 ms with NO frames arriving. Telnyx's WebRTC leg streams RTP continuously (~50 pps) for the whole call, so between turns we decode frames of pure silence -- and we forwarded them. The simulator therefore received a media frame every 20 ms for the entire call and concluded the assistant never stopped talking: is_assistant_playing() stayed true, _assistant_turn_is_settled() never became true, the caller's response task waited forever (8x caller_response_coalesced, caller_response_created never logged), and the record died with zero user turns. Drop frames whose RMS is at or below ASSISTANT_SILENCE_RMS so the simulator sees a real gap. EVA CLI, same command, before and after: before after extracted turns 1 assistant, 0 user 6 assistant, 6 user audio_user_clean.wav 0.0s (caller never spoke) 39.9s of real speech The record still fails, but now for a different and clearly external reason: the assistant does not receive the caller's audio at all. It re-asks a question the caller has already answered and falls into its no-input script -- consistent with Telnyx's own dual-channel recording, where the caller track is digital silence. That is a Telnyx-side media bug (VSDK-277), not an EVA-contract bug. --- src/eva/assistant/telnyx_server.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/eva/assistant/telnyx_server.py b/src/eva/assistant/telnyx_server.py index cfbb4016..764baa0e 100644 --- a/src/eva/assistant/telnyx_server.py +++ b/src/eva/assistant/telnyx_server.py @@ -69,6 +69,9 @@ 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 @@ -1682,6 +1685,16 @@ async def _on_telnyx_audio(self, pcm_16k: bytes, audio_output_queue: asyncio.Que 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: From c3411478c1946e0e476028d4fce304530a49a47c Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 15 Jul 2026 11:51:00 -0300 Subject: [PATCH 10/15] feat(telnyx): auto-provision the media-streaming transport from just an API key Separate track from the direct-WebRTC path: reach the AI Assistant via a Call Control outbound call + Media Streaming WebSocket (bidirectional PCMU) instead of anonymous WebRTC. No ICE/DTLS/SRTP, so the b2bua-rtc media interop bug (VSDK-277) does not apply. The TelnyxCallControlTransport and its selection already exist on this branch; this adds the piece that was missing: turning a bare API key into a working configuration. eva.assistant.telnyx_provisioning: - Discovers or creates every account resource the transport needs: a Call Control Application (the connection, carrying the webhook URL + outbound settings), an idle phone number for caller ID, and an outbound voice profile. - Reuse-first and idempotent: reuses an existing profile, an existing eva-media-streaming connection, and an UNATTACHED active number (so it never disturbs a number already routing traffic). Re-running is a no-op; a changed public URL re-points the webhook. - Read-only inspect by default; creation gated behind --provision. Buys nothing. - Emits the EVA_MODEL__S2S_PARAMS to drop into .env; telnyx_server then selects the media-streaming transport automatically (_use_call_control keys on connection_id + webhook_base_url). Exercised against a live account: inspect correctly reports the missing connection; --provision created a Call Control app + attached an idle US number + reused the existing outbound profile, all free. docs/telnyx_media_streaming.md explains the flow and, in particular, PUBLIC INGRESS: unlike the outbound-only WebRTC path, Media Streaming requires Telnyx to connect back to EVA (call webhooks + the media WebSocket), so EVA needs a publicly reachable HTTPS/WSS URL -- a tunnel (cloudflared/ngrok) for a laptop/locked-down pod, or a real Ingress/LoadBalancer for a deployment. 5 unit tests (mocked httpx). 211 tests pass, ruff clean. --- docs/telnyx_media_streaming.md | 112 ++++++++ .../assistant/telnyx_provisioning/__init__.py | 18 ++ .../assistant/telnyx_provisioning/__main__.py | 4 + .../telnyx_provisioning/provision.py | 255 ++++++++++++++++++ .../assistant/test_telnyx_provisioning.py | 122 +++++++++ 5 files changed, 511 insertions(+) create mode 100644 docs/telnyx_media_streaming.md create mode 100644 src/eva/assistant/telnyx_provisioning/__init__.py create mode 100644 src/eva/assistant/telnyx_provisioning/__main__.py create mode 100644 src/eva/assistant/telnyx_provisioning/provision.py create mode 100644 tests/unit/assistant/test_telnyx_provisioning.py diff --git a/docs/telnyx_media_streaming.md b/docs/telnyx_media_streaming.md new file mode 100644 index 00000000..97736cb3 --- /dev/null +++ b/docs/telnyx_media_streaming.md @@ -0,0 +1,112 @@ +# 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. + +## 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/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/tests/unit/assistant/test_telnyx_provisioning.py b/tests/unit/assistant/test_telnyx_provisioning.py new file mode 100644 index 00000000..8a507167 --- /dev/null +++ b/tests/unit/assistant/test_telnyx_provisioning.py @@ -0,0 +1,122 @@ +"""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 From fe279feaf01a831cab64d28a0734a246bb5a29a8 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 15 Jul 2026 21:31:35 -0300 Subject: [PATCH 11/15] feat(telnyx): auto-launch a Cloudflare quick tunnel per run (media streaming) EVA is one-shot, so instead of asking the user to run cloudflared and paste a URL, the media-streaming transport can bring up a Cloudflare quick tunnel itself for the duration of the run and use its public URL as webhook_base_url. Set it via s2s_params: "webhook_base_url": "auto" (or "auto_tunnel": true). - telnyx_provisioning/tunnel.py: cloudflare_quick_tunnel(port) async context manager -- launches `cloudflared tunnel --url http://localhost:{port}`, parses the assigned https://<...>.trycloudflare.com URL from its output, tears the process down on exit. Quick tunnels need NO Cloudflare account. Raises CloudflaredNotFound if the binary is absent. - telnyx_server: when webhook_base_url == "auto", after the media server binds its port, start the tunnel, set webhook_base_url to the tunnel URL, and PATCH the Call Control connection's webhook to match (the URL is fresh each run). Torn down in _shutdown. This makes the media-streaming path nearly as turnkey as the WebRTC path: TELNYX_API_KEY + OPENAI_API_KEY + a provisioned connection_id, then `python main.py` -- no manual tunnel, no public-URL wrangling. Needs the cloudflared binary on PATH. 3 unit tests (URL parse + missing-binary), 213 pass, ruff clean. --- .../assistant/telnyx_provisioning/tunnel.py | 73 +++++++++++++++++++ src/eva/assistant/telnyx_server.py | 47 ++++++++++++ .../assistant/test_telnyx_provisioning.py | 41 +++++++++++ 3 files changed, 161 insertions(+) create mode 100644 src/eva/assistant/telnyx_provisioning/tunnel.py diff --git a/src/eva/assistant/telnyx_provisioning/tunnel.py b/src/eva/assistant/telnyx_provisioning/tunnel.py new file mode 100644 index 00000000..d5875e24 --- /dev/null +++ b/src/eva/assistant/telnyx_provisioning/tunnel.py @@ -0,0 +1,73 @@ +"""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 index 764baa0e..2f79849a 100644 --- a/src/eva/assistant/telnyx_server.py +++ b/src/eva/assistant/telnyx_server.py @@ -1263,6 +1263,13 @@ def __init__(self, **kwargs: Any) -> None: 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 "" self._target_version_id = self.s2s_params.get("target_version_id") or self.s2s_params.get("targetVersionId") self._websocket_host = ( @@ -1350,6 +1357,39 @@ async def start(self) -> None: logger.info("Telnyx server started on ws://localhost:%s", self.port) + if self._auto_tunnel and not self._webhook_base_url: + await self._start_auto_tunnel() + + 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 @@ -1360,6 +1400,13 @@ async def _shutdown(self) -> None: 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, diff --git a/tests/unit/assistant/test_telnyx_provisioning.py b/tests/unit/assistant/test_telnyx_provisioning.py index 8a507167..d6482f4c 100644 --- a/tests/unit/assistant/test_telnyx_provisioning.py +++ b/tests/unit/assistant/test_telnyx_provisioning.py @@ -120,3 +120,44 @@ def test_idempotent_when_everything_exists(): 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()) From 221c19ba68593b4404a30dd9de1e2660c76b76f8 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 15 Jul 2026 21:46:49 -0300 Subject: [PATCH 12/15] fix(metrics): audio judge calls Gemini directly, never the LiteLLM router The audio-judge speech-fidelity metrics (user_speech_fidelity, ...) called self.llm_client.generate_text() first -- i.e. the LiteLLM router, with the audio inline -- and only fell back to google.genai directly when Gemini reported "no audio". But the router has no Gemini deployment in many setups, so the first call dies with "no healthy deployments for gemini-3-flash-preview" and the direct-Gemini fallback never runs. (litellm also mis-transforms audio/file_id messages for Gemini.) The audio judge is Gemini-only, so when GEMINI_API_KEY is set it should go straight to Gemini. Upload the audio and use _generate_with_file() (google.genai SDK) from the first attempt, bypassing the router entirely. Fall back to the router only when no GEMINI_API_KEY is set (preserving behaviour for setups that route Gemini through a gateway). Fixes fully-graded runs (e.g. the telnyx media-streaming benchmark), where the conversation succeeds but user_speech_fidelity failed on the router. Regenerated the metric-signature fixture (transport-only change; scoring is unchanged, no version bump). --- src/eva/metrics/speech_fidelity_base.py | 13 +++++++++++++ tests/fixtures/metric_signatures.json | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/eva/metrics/speech_fidelity_base.py b/src/eva/metrics/speech_fidelity_base.py index d6ca93e0..8d95fe37 100644 --- a/src/eva/metrics/speech_fidelity_base.py +++ b/src/eva/metrics/speech_fidelity_base.py @@ -181,6 +181,19 @@ 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}; " + f"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/tests/fixtures/metric_signatures.json b/tests/fixtures/metric_signatures.json index 1d589929..2083399b 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": "4b693e1e2556", "version": "v0.4" }, "TTSFidelityMetric": { "name": "tts_fidelity", "prompt_hash": "c3a02ab03f06", - "source_hash": "10b97af355f7", + "source_hash": "99f02ca00d13", "version": "v0.3" }, "TaskCompletion": { @@ -104,7 +104,7 @@ "UserSpeechFidelityMetric": { "name": "user_speech_fidelity", "prompt_hash": "849e13c42e3b", - "source_hash": "5463b901a11b", + "source_hash": "d6954c3e272a", "version": "v0.2" } } From 43191527f4e86c4e36652668d02ee9163c6bc4b7 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 15 Jul 2026 21:54:53 -0300 Subject: [PATCH 13/15] docs(telnyx): document the auto-tunnel (webhook_base_url=auto) turnkey path + verified graded run --- docs/telnyx_media_streaming.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/telnyx_media_streaming.md b/docs/telnyx_media_streaming.md index 97736cb3..65a13880 100644 --- a/docs/telnyx_media_streaming.md +++ b/docs/telnyx_media_streaming.md @@ -9,6 +9,34 @@ 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 From 438f6115b311769560fb2dd7b8148bb50f58403c Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 15 Jul 2026 22:38:14 -0300 Subject: [PATCH 14/15] feat(telnyx): default create_assistant + fold provisioning into the run Make the media-streaming path fully turnkey from a bare Telnyx API key: - create_assistant now 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. Passing an explicit assistant_id, or create_assistant:false, opts out. - Fold telnyx_provisioning into start() (_auto_provision_resources): discover/ create the Call Control connection + caller-ID number from just the API key, reuse-first and idempotent. No separate provisioning command needed. - Reorder start() so the auto-tunnel comes up before validation and assistant creation (the tool webhook URLs and SIP target are built from the public URL), and derive the SIP 'to' from the created/supplied assistant id. - transport:media_streaming selects Call Control even before connection_id exists (so auto-provision can fill it in). Verified: live EVA CLI run (medical_hr 1.1) against a CLI-generated assistant grades Success 1/1 with task_completion=1.0 (tools executed against the live scenario db). Co-Authored-By: Claude Opus 4.8 --- src/eva/assistant/telnyx_server.py | 78 ++++++++++++++++++++-- tests/unit/assistant/test_telnyx_server.py | 35 ++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/eva/assistant/telnyx_server.py b/src/eva/assistant/telnyx_server.py index 2f79849a..394a27f3 100644 --- a/src/eva/assistant/telnyx_server.py +++ b/src/eva/assistant/telnyx_server.py @@ -1258,6 +1258,7 @@ def __init__(self, **kwargs: Any) -> None: 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 = ( @@ -1271,6 +1272,12 @@ def __init__(self, **kwargs: Any) -> None: 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") @@ -1313,12 +1320,33 @@ async def start(self) -> None: logger.warning("Telnyx server already running") return - self._validate_runtime_config() self.output_dir.mkdir(parents=True, exist_ok=True) self._fw_log = FrameworkLogWriter(self.output_dir) self._metrics_log = MetricsLogWriter(self.output_dir) - if self.s2s_params.get("create_assistant"): + # 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( @@ -1338,6 +1366,15 @@ async def start(self) -> None: 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) @@ -1357,8 +1394,31 @@ async def start(self) -> None: logger.info("Telnyx server started on ws://localhost:%s", self.port) - if self._auto_tunnel and not self._webhook_base_url: - await self._start_auto_tunnel() + 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.""" @@ -1448,7 +1508,9 @@ def get_conversation_stats(self) -> dict[str, Any]: @property def _use_call_control(self) -> bool: - """Whether to use Call Control transport instead of WebRTC.""" + """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: @@ -1464,6 +1526,8 @@ def _validate_runtime_config(self) -> None: 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: @@ -1475,9 +1539,9 @@ def _validate_runtime_config(self) -> None: + "." ) else: - if not self._assistant_id and not self.s2s_params.get("create_assistant"): + if not self._assistant_id and not self._create_assistant: missing.append("assistant_id or assistant_agent_id") - if self.s2s_params.get("create_assistant") and not self._api_key: + if self._create_assistant and not self._api_key: missing.append("api_key or telnyx_api_key") if missing: raise ValueError( diff --git a/tests/unit/assistant/test_telnyx_server.py b/tests/unit/assistant/test_telnyx_server.py index 06edc5f1..f23c41a5 100644 --- a/tests/unit/assistant/test_telnyx_server.py +++ b/tests/unit/assistant/test_telnyx_server.py @@ -298,6 +298,8 @@ def test_validate_runtime_config_requires_assistant_id_only_for_direct_mode(self 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() @@ -314,14 +316,19 @@ def test_validate_runtime_config_does_not_require_call_control_fields(self) -> N 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() @@ -334,6 +341,7 @@ def test_validate_runtime_config_call_control_mode_requires_credentials(self) -> 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", @@ -342,10 +350,37 @@ def test_validate_runtime_config_call_control_mode_passes_with_all_fields(self) "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" From 567f1a10aa4d3fe1de0b658b5c311eb839b2e937 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Mon, 20 Jul 2026 15:56:43 -0300 Subject: [PATCH 15/15] Run pre-commit --- .../assistant/telnyx_provisioning/tunnel.py | 9 +- src/eva/assistant/telnyx_server.py | 30 +-- src/eva/metrics/speech_fidelity_base.py | 3 +- tests/fixtures/metric_signatures.json | 6 +- .../assistant/test_telnyx_provisioning.py | 57 +++-- uv.lock | 199 +++++++++++++++--- 6 files changed, 239 insertions(+), 65 deletions(-) diff --git a/src/eva/assistant/telnyx_provisioning/tunnel.py b/src/eva/assistant/telnyx_provisioning/tunnel.py index d5875e24..ddc27009 100644 --- a/src/eva/assistant/telnyx_provisioning/tunnel.py +++ b/src/eva/assistant/telnyx_provisioning/tunnel.py @@ -57,8 +57,13 @@ async def cloudflare_quick_tunnel(local_port: int, *, url_timeout: float = 30.0) "(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, + 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) diff --git a/src/eva/assistant/telnyx_server.py b/src/eva/assistant/telnyx_server.py index 394a27f3..ac7228ac 100644 --- a/src/eva/assistant/telnyx_server.py +++ b/src/eva/assistant/telnyx_server.py @@ -871,8 +871,7 @@ async def _set_remote_description(self, RTCSessionDescription: Any, sdp: str) -> 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", + "Telnyx WebRTC remote description set. Receivers: %s, Senders: %s, Transceivers: %s", len(self._peer.getReceivers()), len(self._peer.getSenders()), len(self._peer.getTransceivers()), @@ -884,9 +883,11 @@ async def _set_remote_description(self, RTCSessionDescription: Any, sdp: str) -> 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, + " 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), ) @@ -989,10 +990,11 @@ async def _receive_remote_audio(self, track: Any) -> None: 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)) + 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) @@ -1533,11 +1535,7 @@ def _validate_runtime_config(self) -> None: 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) - + "." - ) + 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") @@ -1914,7 +1912,9 @@ def _append_user_events_from_simulator(self) -> None: 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) + 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): diff --git a/src/eva/metrics/speech_fidelity_base.py b/src/eva/metrics/speech_fidelity_base.py index 8d95fe37..38cf11d2 100644 --- a/src/eva/metrics/speech_fidelity_base.py +++ b/src/eva/metrics/speech_fidelity_base.py @@ -191,8 +191,7 @@ async def _call_and_parse( used_file_upload = True except Exception as upload_err: self.logger.error( - f"[{context.record_id}] Gemini file upload failed: {upload_err}; " - f"falling back to the router." + 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: diff --git a/tests/fixtures/metric_signatures.json b/tests/fixtures/metric_signatures.json index 2083399b..ab39545a 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": "4b693e1e2556", + "source_hash": "d9c61ce8ac29", "version": "v0.4" }, "TTSFidelityMetric": { "name": "tts_fidelity", "prompt_hash": "c3a02ab03f06", - "source_hash": "99f02ca00d13", + "source_hash": "62ada31835fa", "version": "v0.3" }, "TaskCompletion": { @@ -104,7 +104,7 @@ "UserSpeechFidelityMetric": { "name": "user_speech_fidelity", "prompt_hash": "849e13c42e3b", - "source_hash": "d6954c3e272a", + "source_hash": "88a666dad6e2", "version": "v0.2" } } diff --git a/tests/unit/assistant/test_telnyx_provisioning.py b/tests/unit/assistant/test_telnyx_provisioning.py index d6482f4c..51daddb7 100644 --- a/tests/unit/assistant/test_telnyx_provisioning.py +++ b/tests/unit/assistant/test_telnyx_provisioning.py @@ -1,4 +1,5 @@ """Unit tests for the Telnyx media-streaming provisioner (no network).""" + from __future__ import annotations import pytest @@ -101,8 +102,13 @@ def test_prefers_unattached_number(): {"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"}], + 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) @@ -112,8 +118,13 @@ def test_prefers_unattached_number(): 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"}], + 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) @@ -124,40 +135,54 @@ def test_idempotent_when_everything_exists(): class _FakeStderr: def __init__(self, lines): - self._lines=list(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 + 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" + + 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/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 = [