Add Deepgram Voice Agent framework support#144
Conversation
Adds a `deepgram` assistant-server framework backed by Deepgram's Voice Agent API (unified STT->LLM->TTS over a single WebSocket), so it can be benchmarked like the existing S2S frameworks. - New DeepgramAssistantServer (src/eva/assistant/deepgram_server.py), modeled on the Gemini Live server and the assistant_server_contract. - Register `deepgram` in worker._get_server_class and the framework Literal. - Parse raw WebSocket JSON by event `type` rather than the SDK's typed iterator, which in deepgram-sdk 6.1.x mis-deserializes every agent event as the same model (dropping transcripts and tool-call requests). - KeepAlive task to prevent Deepgram's ~10s input-audio timeout from closing the session while the (half-duplex) agent is speaking. - Compute model_response latency from the server-side receipt time of user_speech_stop (the simulator emits it on a monotonic clock). - Unit tests for settings/tool conversion + framework dispatch test. - Docs section in assistant_server_contract.md; .env.example framework enum. - Bump simulation_version 2.0.0 -> 2.1.0 (affects benchmark outputs).
…agent-framework # Conflicts: # src/eva/__init__.py
Co-authored-by: Katrina Stankiewicz <katrina.stankiewicz@servicenow.com>
Builds on the code-review suggestions (model now required in s2s_params;
use base self.language):
- Evaluate Deepgram as a cascade pipeline (get_pipeline_type -> CASCADE) since
it runs STT->LLM->TTS internally, so stt_wer / transcription_accuracy /
speakability run; expose {stt, llm, tts} via pipeline_parts so the run_id
folder shows the three component models.
- Add optional `think_label` to decouple the short metrics/run_id label from the
(long) Deepgram model id (Deepgram still receives `model`).
- Fix test broken by the review suggestion: _bare_server set `_language` but the
server now reads base `self.language`.
- Docs updated to match.
Add bring-your-own credentials/endpoint for the Voice Agent think step (s2s_params.think_credentials / think_endpoint / think_params / context_length / think_region) so the LLM can run on your own Bedrock/Anthropic/etc. quota instead of Deepgram's managed allowance — needed for long prompts that exceed managed limits and for an apples-to-apples comparison with the cascade pipeline. Managed config is unchanged; BYO is fully opt-in. For aws_bedrock, IAM/STS creds are built from AWS_* env vars when think_credentials is omitted. Note: Deepgram's aws_bedrock think integration only supports Claude 3.5-era models (e.g. anthropic/claude-3-5-haiku-20240307-v1:0). Haiku 4.5 is rejected with 'Invalid agent.think settings - model not available', so BYO Bedrock cannot match the cascade pipeline's Bedrock Haiku 4.5 — use managed or BYO Anthropic-direct (claude-haiku-4-5) for Haiku 4.5. Surface think outages loudly: a provider Error, or a greeting-only conversation (caller spoke but the model never replied), is now logged at ERROR with guidance instead of silently producing a clean-looking 1-turn run. Handle Welcome/SettingsApplied/History/AgentThinking events explicitly. Bumps simulation_version 2.0.2 -> 2.0.3.
…k settings The _bare_server() helper bypasses __init__, so it needed the new BYO attributes (_think_credentials/_think_endpoint/_think_params/_context_length/ _think_region) set explicitly. Also add coverage for BYO: bedrock credentials (explicit + AWS_* env-built), endpoint/params/context_length passthrough, and that the managed default is unchanged.
Custom Google endpoints require the model in the URL (.../models/<model>:streamGenerateContent) and reject it in settings, but the SDK's typed AgentV1Settings *requires* provider.model. Detect this case (think_provider=google + think_endpoint), omit model from the provider, and send the raw settings dict via the SDK's low-level _send to bypass the typed-model validation. Non-Google paths are unchanged (still validated + send_settings). Verified live: model-less google + streamGenerateContent drives multi-turn think. Bumps simulation_version 2.0.3 -> 2.0.4.
Log the Welcome event's request_id at INFO so each conversation can be traced in Deepgram's server-side logs / support tickets. Bumps simulation_version 2.0.4 -> 2.0.5.
| self._listen_model: str = s2s_params.get("listen_model", _DEFAULT_LISTEN_MODEL) | ||
| self._speak_model: str = s2s_params.get("speak_model", _DEFAULT_SPEAK_MODEL) |
There was a problem hiding this comment.
Instead of introducing listen_model and speak_model in s2s_params, I think it would be possible to use the regular model in stt_params and tts_params. Have you considered that? I'm not sure whether that's a good idea. Let's brainstorm. What do you think?
| self._listen_model: str = s2s_params.get("listen_model", _DEFAULT_LISTEN_MODEL) | |
| self._speak_model: str = s2s_params.get("speak_model", _DEFAULT_SPEAK_MODEL) | |
| self._listen_model: str = (self.pipeline_config.stt_params or {}).get("model", _DEFAULT_LISTEN_MODEL) | |
| self._speak_model: str = (self.pipeline_config.tts_params or {}).get("model", _DEFAULT_SPEAK_MODEL) |
There was a problem hiding this comment.
Yeah, I considered it. Downside is that Deepgram Voice Agent always uses Deepgram for STT/TTS on the same api_key: stt_params/tts_params normally pair with the stt/tts provider fields, so populating them here implies a swap-ability that doesn't exist. Keeping listen_model/speak_model in s2s_params keeps all the Voice Agent config in one validated place. Not strongly attached though, happy to switch if you prefer the consistency. WDYT?
There was a problem hiding this comment.
Good point about the api_key; we wouldn't want to have it required in stt_params and tts_params. But, it wouldn't be, since _check_companion_services() won't check stt_params and tts_params if pipeline_type is S2S. So I think we're good; we can define model (and even alias) in stt_params and tts_params without defining api_key.
I'll update my suggestion to make sure we're imagining the same thing.
There are multiple possibilities. I'm not sure which one is the clearest. Maybe it's yours. What do you think?
There was a problem hiding this comment.
Cool! I don't understand all the implementation details, but that looks very complete. 👍
Co-authored-by: Joseph Marinier <joseph.marinier@servicenow.com>
… think_label->alias Address PR review (items 3 & 4): - Move DEFAULT_LISTEN_MODEL / DEFAULT_SPEAK_MODEL into config.py and import them into deepgram_server.py so pipeline_parts and the server share one source of truth (config.py can't import the assistant layer without pulling the deepgram SDK). - pipeline_parts deepgram branch now uses _param_alias(p), dropping the vestigial think_model fallback, consistent with the cascade/elevenlabs branches. - Rename the s2s_params 'think_label' key to 'alias' so _param_alias picks it up and the config matches the rest of the codebase; update the server contract doc.
s2s_params is guaranteed non-None by the _check_companion_services validator, so access it directly (matches the elevenlabs branch above). Addresses PR review.
JosephMarinier
left a comment
There was a problem hiding this comment.
Here's one possibility: reuse the existing stt/tts_params.get("model") (and even stt/tts_params.get("alias") for the config's pipeline_parts).
| # s2s_params is guaranteed non-None by _check_companion_services(). | ||
| return { | ||
| "stt": self.s2s_params.get("listen_model", DEFAULT_LISTEN_MODEL), | ||
| "llm": _param_alias(self.s2s_params), | ||
| "tts": self.s2s_params.get("speak_model", DEFAULT_SPEAK_MODEL), |
There was a problem hiding this comment.
| # s2s_params is guaranteed non-None by _check_companion_services(). | |
| return { | |
| "stt": self.s2s_params.get("listen_model", DEFAULT_LISTEN_MODEL), | |
| "llm": _param_alias(self.s2s_params), | |
| "tts": self.s2s_params.get("speak_model", DEFAULT_SPEAK_MODEL), | |
| # s2s_params is guaranteed non-None by _check_companion_services(), but not stt_params and tts_params. | |
| return { | |
| "stt": _param_alias(self.stt_params or {}) or DEFAULT_LISTEN_MODEL, | |
| "llm": _param_alias(self.s2s_params), | |
| "tts": _param_alias(self.tts_params or {}) or DEFAULT_SPEAK_MODEL, |
|
|
||
| def _param_alias(params: dict[str, Any]) -> str: | ||
| """Return the display alias from a params dict.""" | ||
| return params.get("alias") or params["model"] |
There was a problem hiding this comment.
| return params.get("alias") or params["model"] | |
| return params.get("alias") or params.get("model") |
| self._listen_model: str = s2s_params.get("listen_model", DEFAULT_LISTEN_MODEL) | ||
| self._speak_model: str = s2s_params.get("speak_model", DEFAULT_SPEAK_MODEL) |
There was a problem hiding this comment.
| self._listen_model: str = s2s_params.get("listen_model", DEFAULT_LISTEN_MODEL) | |
| self._speak_model: str = s2s_params.get("speak_model", DEFAULT_SPEAK_MODEL) | |
| self._listen_model: str = (self.pipeline_config.stt_params or {}).get("model", DEFAULT_LISTEN_MODEL) | |
| self._speak_model: str = (self.pipeline_config.tts_params or {}).get("model", DEFAULT_SPEAK_MODEL) |
JosephMarinier
left a comment
There was a problem hiding this comment.
And here's another even simpler possibility: reuse the existing stt/tts.
| "stt": self.s2s_params.get("listen_model", DEFAULT_LISTEN_MODEL), | ||
| "llm": _param_alias(self.s2s_params), | ||
| "tts": self.s2s_params.get("speak_model", DEFAULT_SPEAK_MODEL), |
There was a problem hiding this comment.
| "stt": self.s2s_params.get("listen_model", DEFAULT_LISTEN_MODEL), | |
| "llm": _param_alias(self.s2s_params), | |
| "tts": self.s2s_params.get("speak_model", DEFAULT_SPEAK_MODEL), | |
| "stt": self.stt or DEFAULT_LISTEN_MODEL, | |
| "llm": _param_alias(self.s2s_params), | |
| "tts": self.tts or DEFAULT_SPEAK_MODEL, |
| self._listen_model: str = s2s_params.get("listen_model", DEFAULT_LISTEN_MODEL) | ||
| self._speak_model: str = s2s_params.get("speak_model", DEFAULT_SPEAK_MODEL) |
There was a problem hiding this comment.
| self._listen_model: str = s2s_params.get("listen_model", DEFAULT_LISTEN_MODEL) | |
| self._speak_model: str = s2s_params.get("speak_model", DEFAULT_SPEAK_MODEL) | |
| self._listen_model: str = self.pipeline_config.stt or DEFAULT_LISTEN_MODEL | |
| self._speak_model: str = self.pipeline_config.tts or DEFAULT_SPEAK_MODEL |
Summary
Adds a new
deepgramassistant-server framework backed by Deepgram's Voice Agent API (unified STT→LLM→TTS over a single WebSocket), so the Deepgram agent can be benchmarked like the existing S2S frameworks. Modeled on the Gemini Live server and thedocs/assistant_server_contract.mdcontract.Although it is configured through
s2s_params, the Voice Agent is a cascade internally (STT→LLM→TTS), so it is scored as a CASCADE pipeline — STT WER, transcription accuracy, and speakability metrics all run, and the run_id/folder exposes the three component models ({stt, llm, tts}).Changes
src/eva/assistant/deepgram_server.py(DeepgramAssistantServer): bridges the Twilio-framed user-simulator WebSocket ↔client.agent.v1.connect(), with the standard audit-log / framework-log / metrics-log / audio-buffer handling.deepgramadded toworker._get_server_class()and theframeworkLiteralinmodels/config.py.get_pipeline_type()mapsdeepgram→CASCADEfor scoring;pipeline_partsexposes{stt: listen_model, llm: alias-or-model, tts: speak_model}via the shared_param_alias()helper and sharedDEFAULT_LISTEN_MODEL/DEFAULT_SPEAK_MODELconstants.aws_bedrock(IAM/STS creds, auto-built fromAWS_*env vars when omitted), Anthropic/OpenAI/Google viathink_endpoint, plusthink_paramsandcontext_lengthpassthrough. Managed config is unchanged..../models/<model>:streamGenerateContent) and be omitted from settings, by sending the raw settings dict past the SDK's typed-model validation for that case.Error, or a greeting-only conversation (caller spoke but the model never replied), is logged atERRORwith guidance instead of silently producing a clean-looking 1-turn run.tests/unit/assistant/test_deepgram_server.py(settings + tool-conversion + BYO-think coverage) and a dispatch case intest_framework_dispatch.py.assistant_server_contract.md;deepgramadded to the framework enum in.env.example.simulation_version2.0.0 → 2.0.1. This only adds a new framework; it does not change any existing framework's outputs, so old simulations do not need re-running.Implementation notes (found via live testing)
typefield rather than the SDK's typed event iterator, whose response-union deserialization mis-routed events (dropping transcripts and tool-call requests) — parsing the JSON directly is deterministic. Binary frames (TTS audio) are handled unchanged.KeepAlivetask prevents Deepgram's ~10s input-audio timeout from closing the session while the half-duplex agent is speaking.model_responselatency is measured from the server-side receipt time of the simulator'suser_speech_stopevent (the simulator emits it on a monotonic clock).Welcomeevent'srequest_idis logged atINFOso each conversation can be traced in Deepgram's server-side logs / support tickets.Known limitation — BYO Bedrock model coverage
Deepgram's
aws_bedrockthink integration only supports Claude 3.5-era models (e.g.anthropic/claude-3-5-haiku-20240307-v1:0). Haiku 4.5 is rejected with "Invalid agent.think settings - model not available", so BYO Bedrock cannot match the cascade pipeline's Bedrock Haiku 4.5 — use managed or BYO Anthropic-direct (claude-haiku-4-5) for Haiku 4.5.Config example
EVA_FRAMEWORK=deepgram EVA_MODEL__S2S=deepgram EVA_MODEL__S2S_PARAMS='{"api_key":"<deepgram-key>","model":"gpt-4o-mini"}'Optional
s2s_params(defaults in parentheses):think_provider(open_ai),listen_model(nova-3),speak_model(aura-2-thalia-en),alias(short metrics/run_id label; Deepgram still receivesmodel). BYO think (all optional):think_credentials,think_endpoint,think_params,context_length,think_region. The conversation language comes from the run'slanguage, nots2s_params.Testing
ruff+ruff formatclean;pre-commit run --all-filespasses.