feat(telnyx): Telnyx assistant server framework (Media Streaming default)#188
feat(telnyx): Telnyx assistant server framework (Media Streaming default)#188lucasassisrosa wants to merge 19 commits into
Conversation
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.
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.
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.
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.
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.
…y 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.
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.
…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
…ts 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.
…an API key
Separate track from the direct-WebRTC path: reach the AI Assistant via a Call Control
outbound call + Media Streaming WebSocket (bidirectional PCMU) instead of anonymous WebRTC.
No ICE/DTLS/SRTP, so the b2bua-rtc media interop bug (VSDK-277) does not apply. The
TelnyxCallControlTransport and its selection already exist on this branch; this adds the
piece that was missing: turning a bare API key into a working configuration.
eva.assistant.telnyx_provisioning:
- Discovers or creates every account resource the transport needs: a Call Control
Application (the connection, carrying the webhook URL + outbound settings), an idle
phone number for caller ID, and an outbound voice profile.
- Reuse-first and idempotent: reuses an existing profile, an existing eva-media-streaming
connection, and an UNATTACHED active number (so it never disturbs a number already
routing traffic). Re-running is a no-op; a changed public URL re-points the webhook.
- Read-only inspect by default; creation gated behind --provision. Buys nothing.
- Emits the EVA_MODEL__S2S_PARAMS to drop into .env; telnyx_server then selects the
media-streaming transport automatically (_use_call_control keys on connection_id +
webhook_base_url).
Exercised against a live account: inspect correctly reports the missing connection;
--provision created a Call Control app + attached an idle US number + reused the existing
outbound profile, all free.
docs/telnyx_media_streaming.md explains the flow and, in particular, PUBLIC INGRESS: unlike
the outbound-only WebRTC path, Media Streaming requires Telnyx to connect back to EVA (call
webhooks + the media WebSocket), so EVA needs a publicly reachable HTTPS/WSS URL -- a tunnel
(cloudflared/ngrok) for a laptop/locked-down pod, or a real Ingress/LoadBalancer for a
deployment.
5 unit tests (mocked httpx). 211 tests pass, ruff clean.
…reaming)
EVA is one-shot, so instead of asking the user to run cloudflared and paste a URL, the
media-streaming transport can bring up a Cloudflare quick tunnel itself for the duration of
the run and use its public URL as webhook_base_url. Set it via s2s_params:
"webhook_base_url": "auto" (or "auto_tunnel": true).
- telnyx_provisioning/tunnel.py: cloudflare_quick_tunnel(port) async context manager --
launches `cloudflared tunnel --url http://localhost:{port}`, parses the assigned
https://<...>.trycloudflare.com URL from its output, tears the process down on exit.
Quick tunnels need NO Cloudflare account. Raises CloudflaredNotFound if the binary is absent.
- telnyx_server: when webhook_base_url == "auto", after the media server binds its port, start
the tunnel, set webhook_base_url to the tunnel URL, and PATCH the Call Control connection's
webhook to match (the URL is fresh each run). Torn down in _shutdown.
This makes the media-streaming path nearly as turnkey as the WebRTC path: TELNYX_API_KEY +
OPENAI_API_KEY + a provisioned connection_id, then `python main.py` -- no manual tunnel, no
public-URL wrangling. Needs the cloudflared binary on PATH.
3 unit tests (URL parse + missing-binary), 213 pass, ruff clean.
…uter The audio-judge speech-fidelity metrics (user_speech_fidelity, ...) called self.llm_client.generate_text() first -- i.e. the LiteLLM router, with the audio inline -- and only fell back to google.genai directly when Gemini reported "no audio". But the router has no Gemini deployment in many setups, so the first call dies with "no healthy deployments for gemini-3-flash-preview" and the direct-Gemini fallback never runs. (litellm also mis-transforms audio/file_id messages for Gemini.) The audio judge is Gemini-only, so when GEMINI_API_KEY is set it should go straight to Gemini. Upload the audio and use _generate_with_file() (google.genai SDK) from the first attempt, bypassing the router entirely. Fall back to the router only when no GEMINI_API_KEY is set (preserving behaviour for setups that route Gemini through a gateway). Fixes fully-graded runs (e.g. the telnyx media-streaming benchmark), where the conversation succeeds but user_speech_fidelity failed on the router. Regenerated the metric-signature fixture (transport-only change; scoring is unchanged, no version bump).
…y path + verified graded run
Make the media-streaming path fully turnkey from a bare Telnyx API key: - create_assistant now defaults to true; on each run EVA provisions a fresh assistant from the selected domain's agent config (instructions + tools wired to EVA's own /tools webhook) and deletes it on shutdown. Passing an explicit assistant_id, or create_assistant:false, opts out. - Fold telnyx_provisioning into start() (_auto_provision_resources): discover/ create the Call Control connection + caller-ID number from just the API key, reuse-first and idempotent. No separate provisioning command needed. - Reorder start() so the auto-tunnel comes up before validation and assistant creation (the tool webhook URLs and SIP target are built from the public URL), and derive the SIP 'to' from the created/supplied assistant id. - transport:media_streaming selects Call Control even before connection_id exists (so auto-provision can fill it in). Verified: live EVA CLI run (medical_hr 1.1) against a CLI-generated assistant grades Success 1/1 with task_completion=1.0 (tools executed against the live scenario db). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Hey Lucas! 👋 Thank you for your PR! We'll take a look soon. When you get a chance, can you please run this? uv run pre-commit run --all-files
git commit --message "Run pre-commit" --no-verify
git pushThis will
|
|
Done — ran |
|
I’m Ahmed, TPM on the Telnyx side working with Lucas on this. I wanted to introduce myself and see whether there’s anything we can do to help make review of this PR easier. If there are specific blockers or concerns, I’d really appreciate the guidance so we can help move it forward efficiently. |
katstankiewicz
left a comment
There was a problem hiding this comment.
Review: feat(telnyx): Telnyx assistant server framework
Thanks @lucasassisrosa — this is a clean, well-documented addition. The code matches the surrounding style, the base-server contract is respected, and the unit tests cover the meaningful branching (provisioning idempotency, transport selection, validation per-mode, payload helpers). A few things worth addressing before merge:
Worth discussing before merge
1. aiohttp / aiortc / soxr are added as hard top-level dependencies.
aiohttp is imported unconditionally at the top of telnyx_server.py (line 43), and all three land in pyproject.toml core dependencies. Since telnyx is one of six frameworks and most runs won't use it, consider whether these should be optional extras rather than forced on every install. aiohttp in particular pulls a heavy transitive footprint into every environment for a single framework. (If the project intentionally keeps all framework deps in core for simplicity, that's fine — just flag it.)
2. The speech_fidelity_base.py change ships a behavior change to the Gemini audio judge for all runs, not just Telnyx.
The PR is titled around adding the Telnyx framework, but speech_fidelity_base.py:184-195 changes the judge path: when GEMINI_API_KEY is set, it always uploads the audio and calls google.genai directly, bypassing the LiteLLM router entirely. That's a real behavior change affecting every speech-fidelity / user-speech-fidelity / TTS-fidelity run. The metric-signature source_hash bumps in tests/fixtures/metric_signatures.json reflect this. Two asks:
- Split this into its own commit/PR, or at minimum call it out explicitly in the PR body — a reviewer scanning "Telnyx framework" would otherwise miss that the shared audio-judge path changed for everyone.
- Confirm the always-upload behavior is intended. Previously upload happened only as a fallback on "no audio"; now it's the primary path whenever a Gemini key is present, which changes cost/latency characteristics for every metrics run.
Correctness / robustness
3. The direct-Gemini path no longer logs token usage.
On the file-upload path, _generate_with_file is called but _log_token_usage is not (line 200 vs the router path at lines 202-205). The genai response's usage_metadata is also discarded inside _generate_with_file. If token accounting matters for these runs (it does elsewhere in the repo for cost tracking), the direct path silently drops it.
4. Auto-provision can silently reassign a number that's attached to another connection.
provision.py:117-131 — pick_from_number prefers unattached numbers, but falls back to any active number (including ones attached to other connections) when none are unattached, and provision.py:195-202 then PATCHes that number's connection_id to the managed connection. For a benchmarking tool that may run against a shared Telnyx account, silently re-pointing a number that's in production use elsewhere is risky. Consider refusing to reassign an already-attached number unless --from-number explicitly names it, or at least emitting a loud warning.
5. Created assistants leak on crash.
create_assistant defaults to True (telnyx_server.py:1281-1282), so every run that doesn't supply an explicit assistant_id creates a Telnyx assistant and relies on _shutdown to delete it (line 1472). If the run crashes before _shutdown reaches the delete (KeyboardInterrupt, OOM, exception during transport teardown), the assistant leaks on the Telnyx account. The provisioner reconciles connections/numbers idempotently, but there's no equivalent reaping of orphaned created assistants on the next run. Consider either persisting the created id for reaping next run, or documenting that crash-recovery of created assistants is manual.
6. _point_connection_webhook re-points a (potentially shared) connection's webhook to the per-run tunnel URL.
This is correct during a run with an ephemeral tunnel, but the call-control application is reuse-first/idempotent and may be shared across runs. If two runs ever overlap on the same account/connection they'll race on the webhook URL. A one-line caveat in docs/telnyx_media_streaming.md (auto-tunnel + shared connection assumes non-overlapping runs) would cover it.
Minor / nits
7. TelnyxAssistantManager.DEFAULT_MODEL = "moonshotai/Kimi-K2.5" — the created assistant falls back to this model unless model is set in s2s_params, and start() passes self._model which itself defaults to DEFAULT_MODEL. So unless the caller explicitly sets model, every created assistant uses Kimi-K2.5. Worth confirming this is the intended benchmark default rather than just a last-resort fallback (the PR body's Summit Health example suggests the agent config should drive the model).
8. The worker.py error message and config.py Literal are both updated consistently. ✓
9. _disable_dtls_session_tickets monkeypatches RTCCertificate._create_ssl_context globally (guarded by a module-level flag so idempotent). The comment explains the rationale well; the global patch is acceptable for a single-process CLI. ✓
Summary
The Telnyx framework integration itself is solid and well-tested. The main asks before merge:
- Split or explicitly justify the
speech_fidelity_base.pyaudio-judge behavior change (it affects all runs, not just Telnyx), and confirm the always-upload-to-Gemini path is intended. - Decide whether
aiohttp/aiortc/soxrbelong in core deps or as an optional extra. - Address the assistant-leak-on-crash risk (or document it), and the silent-number-reassignment risk in the provisioner.
Happy to dig deeper into any of these.
Adds Telnyx hosted AI Assistant as a first-class EVA framework (
EVA_FRAMEWORK=telnyx), bridging the EVA user simulator to a Telnyx assistant and enriching the audit log from the Conversations API.Two transports are implemented; Media Streaming is the default and recommended path.
Media Streaming (default) — verified working end-to-end
Reaches the assistant via Call Control + a bidirectional PCMU Media Streaming WebSocket — no WebRTC, no ICE/DTLS/SRTP. In a live EVA CLI run the assistant heard the caller and responded to what they said:
audio_user_clean.wav= 15.7s of real speech; EVA extracted 2 user turns (the WebRTC path gets 0).Turnkey — just two keys.
eva.assistant.telnyx_provisioningtakes only aTELNYX_API_KEYand discovers/creates everything else (Call Control connection, caller-ID number, outbound profile) — reuse-first, idempotent. Set"webhook_base_url":"auto"and EVA launches a Cloudflare quick tunnel itself for the one-shot run (no account, no manual tunnel), so Telnyx can reach EVA's media WebSocket + call webhooks. Thenpython main.py.docs/telnyx_media_streaming.mdcovers the flow and public-ingress options (tunnel, in-cluster ingress). A ready-to-run deploy image lives atteam-telnyx/eva-media-streaming.Direct WebRTC (alternate) — known limitation
TelnyxWebRTCClientreaches the assistant with only a publicassistant_id(no API key, no ingress) via anonymous Verto/WebRTC. Currently blocked by a Telnyx b2bua-rtc inbound-media issue (VSDK-277): the assistant does not receive the caller's audio. ICE (aiortc + Pion both fail), signaling, and SDP have all been excluded; a fix ships here already (SSL_OP_NO_TICKETrestores inbound audio). Kept for the id-only use case; use Media Streaming for a reliable graded run.Contract
Subclasses
AbstractAssistantServer, registered in_get_server_class(); serves the Twilio-framedws://localhost:{port}/ws; routes tools throughToolExecutor; writes the audit log, transcript, audio, and scenario DBs.Unit tests for the server, provisioner, and tunnel. Full suite green; ruff clean.
🤖 Generated with Claude Code