From 7376bea4735c1c3da416302d519051a909854701 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 1 Jul 2026 23:39:32 +0000 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 22390ecac40107d92cd9907373047e9fc55ff08f Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 15 Jul 2026 11:57:32 -0300 Subject: [PATCH 10/12] experiment(telnyx): Pion probe for the anonymous-WebRTC path (VSDK-277) Standalone Go program that drives the same anonymous_login -> telnyx_rtc.invite -> answer flow as PR #173's TelnyxWebRTCClient, but with Pion's (browser-class) ICE stack instead of aiortc/aioice. Answers one question: does Pion's ICE get caller audio through b2bua-rtc where aioice does not? Motivation: every non-ICE hypothesis for VSDK-277 has been excluded, the browser (libwebrtc ICE) works, and aioice still does RFC 5245 aggressive nomination (removed in RFC 8445). Forcing regular nomination in aioice did NOT fix it, so the next test is a genuinely different, browser-class ICE stack. Status: builds and vets clean (go build/vet ./...); NOT yet run against the assistant -- the dev environment blocks execution of freshly-built native binaries (a trivial hello-world Go binary is killed the same way), so it must be run on a normal machine. No Telnyx account or key needed -- anonymous login uses only the public assistant id. README documents how to run it and, if it works, how to integrate Pion behind TelnyxWebRTCClient's small interface (sidecar or full-Go client) while staying on the assistant_id-only path. Includes speech.ogg (48kHz mono opus) as the caller audio sample. --- experiments/pion-probe/.gitignore | 2 + experiments/pion-probe/README.md | 72 ++++++++ experiments/pion-probe/go.mod | 31 ++++ experiments/pion-probe/go.sum | 63 +++++++ experiments/pion-probe/main.go | 290 ++++++++++++++++++++++++++++++ experiments/pion-probe/speech.ogg | Bin 0 -> 39545 bytes 6 files changed, 458 insertions(+) create mode 100644 experiments/pion-probe/.gitignore create mode 100644 experiments/pion-probe/README.md create mode 100644 experiments/pion-probe/go.mod create mode 100644 experiments/pion-probe/go.sum create mode 100644 experiments/pion-probe/main.go create mode 100644 experiments/pion-probe/speech.ogg diff --git a/experiments/pion-probe/.gitignore b/experiments/pion-probe/.gitignore new file mode 100644 index 00000000..ac50de7e --- /dev/null +++ b/experiments/pion-probe/.gitignore @@ -0,0 +1,2 @@ +pion-probe +*.test diff --git a/experiments/pion-probe/README.md b/experiments/pion-probe/README.md new file mode 100644 index 00000000..9a3f077d --- /dev/null +++ b/experiments/pion-probe/README.md @@ -0,0 +1,72 @@ +# Pion probe — does a browser-class ICE stack fix the Telnyx WebRTC path? + +Standalone Go program that drives the **exact** anonymous-WebRTC flow PR #173's +`TelnyxWebRTCClient` uses (`anonymous_login` → `telnyx_rtc.invite` → answer → media), but +with **Pion** instead of aiortc. + +## Why + +The assistant never hears EVA on the aiortc path (VSDK-277). Everything downstream — codec, +SRTP, region, framing, nomination mode — has been excluded. The browser works, and the one +thing it does differently is run **libwebrtc's ICE**. `aiortc` uses **aioice**, a pure-Python +ICE agent with known nomination deviations (it still does RFC 5245 *aggressive* nomination, +removed in RFC 8445). **Pion's ICE is a browser-class stack.** This probe answers one +question: does Pion's ICE get caller audio through b2bua-rtc where aioice doesn't? + +If it prints `ASSISTANT HEARD US`, the fix is to move PR #173's media engine to Pion. If it +fails the same way, the problem is not the client ICE stack and the escalation stands. + +## Run + +Needs Go 1.21+. **No Telnyx account or API key** — anonymous login uses only the public +assistant id. + +```bash +cd experiments/pion-probe +go mod tidy +go run . -assistant assistant-473093df-d97a-4eae-b484-413bcbe2fda4 -seconds 45 +``` + +`speech.ogg` (48 kHz mono opus, committed) is streamed as the caller after the greeting. + +Expected on success: + +``` +[ws] connected +[verto] session: +[assistant] Thank you for calling Summit Health Plan prior authorization support... +[ice] connected +[audio] streaming caller speech +*** ASSISTANT HEARD US: "I'm calling from a provider office..." *** +==== RESULT ==== +assistant heard us : true (N user transcripts) +``` + +## Status + +**Builds clean; not yet run against the assistant.** It was written and compiled in an +environment that blocks execution of freshly-built native binaries (both `go run` and a +plain external-linked `go build` binary are killed before `main` runs — this affects a +trivial `hello world` too, so it is a sandbox limitation, not a code issue). It needs to be +run on a normal developer machine to get the result. `go build ./...` passes. + +## If it works: integrating Pion into PR #173 + +The swap boundary is small. `TelnyxWebRTCClient` (in `telnyx_server.py`) exposes +`start()`, `stop()`, `send_audio(pcm)`, `build_invite_params(sdp)`, an `audio_handler` +callback, and `disconnected_event`. Everything else in PR #173 (the EVA server contract, +Verto signaling, audio bridge, tool handling) talks only to that surface. + +Two integration shapes: + +1. **Pion as a media sidecar** (recommended): a small Go binary owns the peer connection; + Python keeps the Verto signaling and exchanges SDP + PCM with it over a local Unix + socket. `TelnyxWebRTCClient` becomes a thin wrapper that launches the sidecar. Python + still drives `anonymous_login`/`invite`; Go produces the offer and consumes the answer. + +2. **Pion owns signaling too**: move the whole client to Go, Python just spawns it and + bridges audio + conversation events. Cleaner separation, more Go. + +Either keeps PR #173 on the **anonymous `assistant_id`** path — no account, no ingress — +which is the hard constraint. This probe is shape (2) collapsed into one file, so it doubles +as the skeleton for the sidecar. diff --git a/experiments/pion-probe/go.mod b/experiments/pion-probe/go.mod new file mode 100644 index 00000000..6d1f6910 --- /dev/null +++ b/experiments/pion-probe/go.mod @@ -0,0 +1,31 @@ +module eva/pion-probe + +go 1.21 + +require ( + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/pion/webrtc/v4 v4.0.7 +) + +require ( + github.com/pion/datachannel v1.5.10 // indirect + github.com/pion/dtls/v3 v3.0.4 // indirect + github.com/pion/ice/v4 v4.0.3 // indirect + github.com/pion/interceptor v0.1.37 // indirect + github.com/pion/logging v0.2.2 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.15 // indirect + github.com/pion/rtp v1.8.10 // indirect + github.com/pion/sctp v1.8.35 // indirect + github.com/pion/sdp/v3 v3.0.9 // indirect + github.com/pion/srtp/v3 v3.0.4 // indirect + github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + github.com/pion/turn/v4 v4.0.0 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/crypto v0.29.0 // indirect + golang.org/x/net v0.31.0 // indirect + golang.org/x/sys v0.27.0 // indirect +) diff --git a/experiments/pion-probe/go.sum b/experiments/pion-probe/go.sum new file mode 100644 index 00000000..b77f49ab --- /dev/null +++ b/experiments/pion-probe/go.sum @@ -0,0 +1,63 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= +github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= +github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U= +github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg= +github.com/pion/ice/v4 v4.0.3 h1:9s5rI1WKzF5DRqhJ+Id8bls/8PzM7mau0mj1WZb4IXE= +github.com/pion/ice/v4 v4.0.3/go.mod h1:VfHy0beAZ5loDT7BmJ2LtMtC4dbawIkkkejHPRZNB3Y= +github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= +github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= +github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= +github.com/pion/rtp v1.8.10 h1:puphjdbjPB+L+NFaVuZ5h6bt1g5q4kFIoI+r5q/g0CU= +github.com/pion/rtp v1.8.10/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= +github.com/pion/sctp v1.8.35 h1:qwtKvNK1Wc5tHMIYgTDJhfZk7vATGVHhXbUDfHbYwzA= +github.com/pion/sctp v1.8.35/go.mod h1:EcXP8zCYVTRy3W9xtOF7wJm1L1aXfKRQzaM33SjQlzg= +github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= +github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= +github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= +github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= +github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= +github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= +github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= +github.com/pion/webrtc/v4 v4.0.7 h1:aeq78uVnFZd2umXW0O9A2VFQYuS7+BZxWetQvSp2jPo= +github.com/pion/webrtc/v4 v4.0.7/go.mod h1:oFVBBVSHU3vAEwSgnk3BuKCwAUwpDwQhko1EDwyZWbU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/experiments/pion-probe/main.go b/experiments/pion-probe/main.go new file mode 100644 index 00000000..113e8ea7 --- /dev/null +++ b/experiments/pion-probe/main.go @@ -0,0 +1,290 @@ +// Pion probe for the Telnyx AI-Assistant anonymous-WebRTC path (VSDK-277). +// +// Purpose: aiortc's ICE (aioice) fails to get caller audio through Telnyx's b2bua-rtc +// (the assistant never hears us). The browser works because it uses libwebrtc's ICE. +// Pion has a browser-class ICE stack. This probe drives the exact same anonymous_login + +// Verto invite flow that PR #173's TelnyxWebRTCClient uses, but with Pion instead of +// aiortc, and reports whether the assistant finally hears us. +// +// It: +// 1. dials wss://rtc.telnyx.com, sends anonymous_login (target_type=ai_assistant), +// 2. builds a Pion PeerConnection, adds an opus track from speech.ogg, +// 3. sends telnyx_rtc.invite with the offer SDP, +// 4. applies the answer, streams the audio, and prints ai_conversation events. +// +// "ASSISTANT HEARD US" prints if a role=user item appears -> Pion's media got through. +// +// go run . -assistant assistant-473093df-... +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + "github.com/pion/webrtc/v4" + "github.com/pion/webrtc/v4/pkg/media" + "github.com/pion/webrtc/v4/pkg/media/oggreader" +) + +const ( + rtcHost = "wss://rtc.telnyx.com" + userAgent = "EVA-PionProbe/0.1" +) + +type rpc struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` +} + +func iceServers() []webrtc.ICEServer { + return []webrtc.ICEServer{ + {URLs: []string{"stun:stun.telnyx.com:3478"}}, + {URLs: []string{"stun:stun.l.google.com:19302"}}, + {URLs: []string{"turn:turn.telnyx.com:3478?transport=udp"}, Username: "testuser", Credential: "testpassword"}, + {URLs: []string{"turn:turn.telnyx.com:3478?transport=tcp"}, Username: "testuser", Credential: "testpassword"}, + {URLs: []string{"turns:turn2.telnyx.com:443"}, Username: "testuser", Credential: "testpassword"}, + } +} + +func main() { + assistant := flag.String("assistant", "assistant-473093df-d97a-4eae-b484-413bcbe2fda4", "assistant id") + oggPath := flag.String("audio", "speech.ogg", "opus/ogg file to send as caller audio") + seconds := flag.Int("seconds", 40, "call duration") + flag.Parse() + + fmt.Println("[ws] dialing", rtcHost) + dialer := *websocket.DefaultDialer + dialer.HandshakeTimeout = 15 * time.Second + ws, resp, err := dialer.Dial(rtcHost, nil) + if err != nil { + code := 0 + if resp != nil { + code = resp.StatusCode + } + fatal(fmt.Sprintf("dial rtc (http %d): %v", code, err)) + } + fmt.Println("[ws] connected") + defer ws.Close() + + callID := uuid.NewString() + heard := make(chan string, 4) + answerCh := make(chan string, 1) + sessCh := make(chan string, 1) + + // --- reader: dispatch Verto messages --- + go func() { + for { + _, raw, err := ws.ReadMessage() + if err != nil { + return + } + var m rpc + if json.Unmarshal(raw, &m) != nil { + continue + } + switch { + case m.Result != nil: // reply to our request + var r struct { + Sessid string `json:"sessid"` + SDP string `json:"sdp"` + } + _ = json.Unmarshal(m.Result, &r) + if r.Sessid != "" { + select { + case sessCh <- r.Sessid: + default: + } + } + if r.SDP != "" { + select { + case answerCh <- r.SDP: + default: + } + } + case m.Method == "telnyx_rtc.answer" || m.Method == "telnyx_rtc.media": + var p struct { + SDP string `json:"sdp"` + } + _ = json.Unmarshal(m.Params, &p) + if p.SDP != "" { + select { + case answerCh <- p.SDP: + default: + } + } + replyOK(ws, m.ID, m.Method) + case m.Method == "ai_conversation": + var p struct { + Item struct { + Role string `json:"role"` + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"item"` + } + _ = json.Unmarshal(m.Params, &p) + if p.Item.Role == "user" { + txt := "" + if len(p.Item.Content) > 0 { + txt = p.Item.Content[0].Text + } + heard <- txt + } else if p.Item.Role == "assistant" && len(p.Item.Content) > 0 { + fmt.Printf("[assistant] %.70s\n", p.Item.Content[0].Text) + } + replyOK(ws, m.ID, m.Method) + case m.ID != nil: + replyOK(ws, m.ID, m.Method) + } + } + }() + + // --- anonymous_login --- + send(ws, "anonymous_login", map[string]interface{}{ + "target_type": "ai_assistant", + "target_id": *assistant, + "userVariables": map[string]interface{}{}, + "reconnection": false, + "User-Agent": map[string]string{"sdkVersion": userAgent, "data": "EVA Pion probe"}, + }) + var sessid string + select { + case sessid = <-sessCh: + case <-time.After(10 * time.Second): + fatal("no sessid from anonymous_login") + } + fmt.Println("[verto] session:", sessid) + + // --- Pion peer connection --- + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers()}) + must(err, "new peerconnection") + pc.OnICEConnectionStateChange(func(s webrtc.ICEConnectionState) { + fmt.Println("[ice]", s.String()) + }) + + track, err := webrtc.NewTrackLocalStaticSample( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "eva") + must(err, "new track") + _, err = pc.AddTrack(track) + must(err, "add track") + + inbound := 0 + pc.OnTrack(func(t *webrtc.TrackRemote, _ *webrtc.RTPReceiver) { + for { + if _, _, err := t.ReadRTP(); err != nil { + return + } + inbound++ + } + }) + + offer, err := pc.CreateOffer(nil) + must(err, "create offer") + gather := webrtc.GatheringCompletePromise(pc) + must(pc.SetLocalDescription(offer), "set local desc") + <-gather + + // --- telnyx_rtc.invite --- + send(ws, "telnyx_rtc.invite", map[string]interface{}{ + "sessid": sessid, + "sdp": pc.LocalDescription().SDP, + "dialogParams": map[string]interface{}{ + "callID": callID, + "destination_number": "ai_assistant", + "audio": true, + }, + "User-Agent": userAgent, + }) + + var answer string + select { + case answer = <-answerCh: + case <-time.After(15 * time.Second): + fatal("no answer SDP") + } + must(pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: answer}), "set remote desc") + + // --- stream the opus file after a short wait for the greeting --- + go streamOgg(track, *oggPath) + + deadline := time.After(time.Duration(*seconds) * time.Second) + var transcripts []string + for { + select { + case t := <-heard: + fmt.Printf("\n*** ASSISTANT HEARD US: %q ***\n\n", t) + transcripts = append(transcripts, t) + case <-deadline: + fmt.Println("\n==== RESULT ====") + fmt.Println("ice connection state :", pc.ICEConnectionState().String()) + fmt.Println("inbound RTP packets :", inbound) + fmt.Printf("assistant heard us : %v (%d user transcripts)\n", len(transcripts) > 0, len(transcripts)) + return + } + } +} + +func streamOgg(track *webrtc.TrackLocalStaticSample, path string) { + time.Sleep(9 * time.Second) // let the greeting finish + f, err := os.Open(path) + if err != nil { + fmt.Println("[audio] open:", err) + return + } + defer f.Close() + ogg, _, err := oggreader.NewWith(f) + if err != nil { + fmt.Println("[audio] oggreader:", err) + return + } + fmt.Println("[audio] streaming caller speech") + var lastGranule uint64 + for { + page, hdr, err := ogg.ParseNextPage() + if err != nil { + return + } + gap := time.Duration((hdr.GranulePosition-lastGranule)*1000/48000) * time.Millisecond + lastGranule = hdr.GranulePosition + if gap == 0 { + gap = 20 * time.Millisecond + } + if err := track.WriteSample(media.Sample{Data: page, Duration: gap}); err != nil { + return + } + time.Sleep(gap) + } +} + +func send(ws *websocket.Conn, method string, params interface{}) { + p, _ := json.Marshal(params) + msg, _ := json.Marshal(rpc{JSONRPC: "2.0", ID: uuid.NewString(), Method: method, Params: p}) + _ = ws.WriteMessage(websocket.TextMessage, msg) +} + +func replyOK(ws *websocket.Conn, id interface{}, method string) { + if id == nil { + return + } + res, _ := json.Marshal(map[string]string{"method": method}) + msg, _ := json.Marshal(rpc{JSONRPC: "2.0", ID: id, Result: res}) + _ = ws.WriteMessage(websocket.TextMessage, msg) +} + +func must(err error, ctx string) { + if err != nil { + fatal(ctx + ": " + err.Error()) + } +} +func fatal(s string) { + fmt.Fprintln(os.Stderr, "FATAL:", s) + os.Exit(1) +} diff --git a/experiments/pion-probe/speech.ogg b/experiments/pion-probe/speech.ogg new file mode 100644 index 0000000000000000000000000000000000000000..e5c480659a82e424202d147a9011179ff77ba581 GIT binary patch literal 39545 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 Y=cJU15e21JJO}%@bQRQleomSDKiWCb(EtDd literal 0 HcmV?d00001 From c28f3e3f95ffbad865bd28d91325bdfcb7e58d65 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 15 Jul 2026 12:24:02 -0300 Subject: [PATCH 11/12] ci(telnyx): run the Pion probe on a Linux runner The Pion probe is a native Go binary that cannot be executed on the macOS dev machine where it was written: corporate security policy SIGKILLs freshly-built unsigned Mach-O binaries (ad-hoc codesigning and disabling the shell sandbox both fail identically, incl. for a trivial hello-world, so it is an OS/EDR-level restriction). A Linux GitHub runner has none of that, allows the outbound network the probe needs (TURN over TCP/443 covers ICE even if UDP is filtered), and needs NO secret -- anonymous login uses only the public assistant id. workflow_dispatch runs the probe against a given assistant id and prints/uploads the verdict (does the assistant finally hear us with Pion's ICE?); push build-checks the Go on changes. This is how to get the live Pion result without a macOS box that allows unsigned binaries. --- .github/workflows/telnyx-pion-probe.yml | 56 +++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/telnyx-pion-probe.yml diff --git a/.github/workflows/telnyx-pion-probe.yml b/.github/workflows/telnyx-pion-probe.yml new file mode 100644 index 00000000..3f1c57c3 --- /dev/null +++ b/.github/workflows/telnyx-pion-probe.yml @@ -0,0 +1,56 @@ +# Runs the Pion WebRTC probe against a Telnyx AI Assistant on a Linux runner. +# +# This exists because the probe (a native Go binary) cannot be executed on the macOS dev +# machine where it was written -- corporate security policy SIGKILLs freshly-built unsigned +# Mach-O binaries. A Linux GitHub runner has no such restriction, allows the outbound +# network the probe needs, and needs NO secret: anonymous login uses only the public +# assistant id. +# +# Trigger manually (Actions -> "Telnyx Pion probe" -> Run workflow) with an assistant id. +name: Telnyx Pion probe + +on: + workflow_dispatch: + inputs: + assistant_id: + description: "Telnyx AI Assistant id (assistant-...)" + required: true + default: "assistant-473093df-d97a-4eae-b484-413bcbe2fda4" + seconds: + description: "Call duration (s)" + required: false + default: "45" + push: + paths: + - "experiments/pion-probe/**" # build-check on changes + +jobs: + probe: + runs-on: ubuntu-latest + defaults: + run: + working-directory: experiments/pion-probe + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.21" + - name: Build (always) + run: go build ./... + - name: Vet + run: go vet ./... + - name: Run probe against the assistant (dispatch only) + if: github.event_name == 'workflow_dispatch' + run: | + go run . \ + -assistant "${{ github.event.inputs.assistant_id }}" \ + -seconds "${{ github.event.inputs.seconds }}" \ + | tee probe.log + echo "----- verdict -----" + grep -E "ASSISTANT HEARD US|assistant heard us|ice connection state|inbound RTP" probe.log || true + - name: Upload log + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: pion-probe-log + path: experiments/pion-probe/probe.log From 1b6de17446191361dcf63b925e2225cd40118181 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 15 Jul 2026 19:35:06 -0300 Subject: [PATCH 12/12] experiment(telnyx): log Pion's SDP offer in the probe (compare to browser) --- experiments/pion-probe/main.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/experiments/pion-probe/main.go b/experiments/pion-probe/main.go index 113e8ea7..f681ccc1 100644 --- a/experiments/pion-probe/main.go +++ b/experiments/pion-probe/main.go @@ -191,6 +191,18 @@ func main() { gather := webrtc.GatheringCompletePromise(pc) must(pc.SetLocalDescription(offer), "set local desc") <-gather + // print the non-candidate SDP lines so we can compare Pion's offer to the browser's + fmt.Println("---- PION OFFER (non-candidate) ----") + for _, ln := range splitLines(pc.LocalDescription().SDP) { + if len(ln) >= 11 && ln[:11] == "a=candidate" { + continue + } + if len(ln) >= 13 && ln[:13] == "a=fingerprint" { + continue + } + fmt.Println(ln) + } + fmt.Println("------------------------------------") // --- telnyx_rtc.invite --- send(ws, "telnyx_rtc.invite", map[string]interface{}{ @@ -279,6 +291,22 @@ func replyOK(ws *websocket.Conn, id interface{}, method string) { _ = ws.WriteMessage(websocket.TextMessage, msg) } +func splitLines(s string) []string { + var out, cur = []string{}, "" + for _, r := range s { + if r == '\n' { + out = append(out, cur) + cur = "" + } else if r != '\r' { + cur += string(r) + } + } + if cur != "" { + out = append(out, cur) + } + return out +} + func must(err error, ctx string) { if err != nil { fatal(ctx + ": " + err.Error())