Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ with client.listen.v2.connect(

**No `language` parameter** on v2 — language is implied by model (`flux-general-en`) or hinted via `language_hint` on multi.

For application-controlled turns, use `eot_threshold="1.0"` with a sufficiently large `eot_timeout_ms`, then call `conn.send_force_end_turn()` for the active turn. ForceEndTurn requires deployment enablement; see `examples/16-transcription-force-end-turn.py`.
For application-controlled turns, use `eot_threshold="1.0"` with a sufficiently large `eot_timeout_ms`, then call `conn.send_force_end_turn()` for the active turn. ForceEndTurn is available on Deepgram-hosted deployments, including EU and AU. On self-hosted deployments, it requires the 2026-09-15 release or later with `listen_v2 = true` and `listen_v2_force_end_turn = true` under `[features]`; see `examples/16-transcription-force-end-turn.py`.

## Events (server → client)

Expand Down
3 changes: 2 additions & 1 deletion .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ src/deepgram/speak/v2/socket_client.py

# Agent TTS provider expressivity must reject fractional values rather than
# allowing Pydantic v1 to truncate them before the API sees the request.
# [temporarily frozen — remove when Fern emits a strict integer]
# [temporarily frozen — remove when Pydantic v1 support is dropped]
src/deepgram/types/speak_settings_v1provider.py
src/deepgram/types/deepgram.py

Expand Down Expand Up @@ -270,6 +270,7 @@ tests/custom/test_flux_tts_controls.py
tests/custom/test_topics_intents_v7_compat.py
tests/custom/test_transport.py
tests/custom/test_websocket_streaming_coverage.py
tests/custom/test_voice_agent_force_end_turn_example.py
tests/typecheck/compat_aliases.py

# Wire test with restored compatibility coverage for legacy create-key request alias
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ Current permanently frozen files:
- `tests/custom/test_listen_v1_configure.py` — hand-written regression coverage for Listen V1 Configure message serialization and typed Error response parsing on both synchronous and asynchronous websocket clients
- `tests/custom/test_model_dict_compat.py` — hand-written regression coverage for the read-only subscript compatibility shim on typed Listen V2 response models, including sync/async parsing, every response variant, nested objects, unknown fields, omitted-key behavior, and scope isolation from unrelated generated models
- `tests/custom/test_query_encoder.py` — hand-written regression test that `core/query_encoder.py` coerces Python bools to lowercase `"true"`/`"false"` before `urlencode` so websocket query strings stay wire-correct
- `tests/custom/test_regen_response_fields.py` — hand-written regression test guarding the response fields added by the 2026-08-18 regen: `ListenV1Response/ResultsMetadata.diarize_info` (`model_uuid`, `arch`), per-word `speaker` / `speaker_confidence`, and `ListenV2TurnInfo.trigger`. Same rationale as `test_latency_report_stt_compat.py`: `UncheckedBaseModel` + `skip_validation` makes response parsing lenient, so a future spec removal would silently turn these into `None` rather than failing. Fixtures are real captured payloads; the `trigger` cases pin the parsing contract for the values the server sends (`model` / `manual` / `timeout`) plus the absent case, which is what a deployment without the server-gated `ForceEndTurn` feature returns. Live coverage of the same field lives in `tests/manual/listen/v2/force_end_turn/`
- `tests/custom/test_regen_response_fields.py` — hand-written regression test guarding the response fields added by the 2026-08-18 regen: `ListenV1Response/ResultsMetadata.diarize_info` (`model_uuid`, `arch`), per-word `speaker` / `speaker_confidence`, and `ListenV2TurnInfo.trigger`. Same rationale as `test_latency_report_stt_compat.py`: `UncheckedBaseModel` + `skip_validation` makes response parsing lenient, so a future spec removal would silently turn these into `None` rather than failing. Fixtures are real captured payloads; the `trigger` cases pin the parsing contract for the values the server sends (`model` / `manual` / `timeout`) plus the absent case, which is what a deployment without ForceEndTurn enabled (self-hosted without the feature flag) returns. Live coverage of the same field lives in `tests/manual/listen/v2/force_end_turn/`
- `tests/custom/test_secure_logging.py` — hand-written regression test that the `websockets` Authorization-header DEBUG logs are redacted (API key never logged in clear text)
- `tests/custom/test_speak_v2_interrupt_configure.py` — hand-written coverage for the Speak V2 barge-in / mid-stream reconfigure surface (`send_interrupt`, `send_configure`, `SpeechInterrupted`, `ConfigureSuccess`/`ConfigureFailure`, and the `speed`/`expressivity` connect params)
- `tests/custom/test_voice_agent_force_end_turn_example.py` — hand-written regression coverage that the Voice Agent ForceEndTurn example waits for a rejection queued after `AgentAudioDone`
- `tests/custom/test_text_builder.py`, `tests/custom/test_transport.py` — hand-written tests
- `tests/custom/test_flux_tts_controls.py`, `tests/custom/test_topics_intents_v7_compat.py` — hand-written regression coverage for Flux TTS controls and direct Topics/Intents response compatibility
- `tests/typecheck/compat_aliases.py` — hand-written mypy `assert_type` coverage for backward-compatible alias TypedDicts
Expand All @@ -66,10 +67,10 @@ Current temporarily frozen files:
- `.gitignore` — Fern generates a baseline version and has regenerated it before (`fdcce88`, `ad93815`, `4bee463`). We hand-add the local coverage artifact ignores (`.coverage`, `htmlcov/`, `coverage.xml`). Before each regen, unfreeze and re-diff so Fern's own additions are picked up, then re-apply the three coverage lines.
- `src/deepgram/speak/v1/socket_client.py` — optional message param defaults, broad exception catch
- `src/deepgram/speak/v2/socket_client.py` — same (optional `send_flush`/`send_close`/`send_interrupt` defaults, broad exception catch); new websocket TTS client added in the 2026-07-08 regen. `send_interrupt` carries no required payload so it takes the same optional-default treatment as the other control sends; `send_configure` deliberately keeps its required argument (a Configure with no settings is meaningless)
- `src/deepgram/types/speak_settings_v1provider.py`, `src/deepgram/types/deepgram.py` — validate Agent TTS `expressivity` as `pydantic.StrictInt` so Pydantic v1 rejects fractional values instead of truncating them before they reach the API. Regression coverage in `tests/custom/test_socket_client_shims.py`. Unfreeze when Fern emits a strict integer.
- `src/deepgram/listen/v1/socket_client.py` — same
- `src/deepgram/listen/v2/socket_client.py` — same (broad except, optional `send_close_stream` default). As of the 2026-08-11 regen the generator properly types `send_configure(ListenV2Configure)` and puts `ListenV2ConfigureSuccess` in the response Union, so those are taken from the generator; the only `send_configure` patch retained is runtime tolerance for a raw dict (sent verbatim) for back-compat with pre-typed-model callers
- `src/deepgram/agent/v1/socket_client.py` — same + `_sanitize_numeric_types`
- `src/deepgram/types/speak_settings_v1provider.py`, `src/deepgram/types/deepgram.py` — validate Agent TTS `expressivity` as `pydantic.StrictInt` so Pydantic v1 rejects fractional values instead of truncating them before they reach the API. Regression coverage in `tests/custom/test_socket_client_shims.py`. Unfreeze when Pydantic v1 support is dropped.
- `src/deepgram/agent/v1/types/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent.py`, `src/deepgram/agent/v1/types/agent_v1settings.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent.py`, `src/deepgram/agent/v1/requests/agent_v1settings.py` — backward-compat patches for the 2026-05-05 Agent Settings schema restructure. These preserve callable `AgentV1SettingsAgent(...)`, keep `AgentV1Settings.agent` accepting both that wrapper and `agent_id` strings, restore the legacy request TypedDict shapes, remap legacy `messages=[...]` / nested `context=AgentV1SettingsAgentContext(messages=[...])` usage into the new `context={"messages": [...]}` wire shape, and keep read-side `obj.messages` access working.
- `src/deepgram/core/api_error.py`, `src/deepgram/core/parse_error.py` — credential redaction. Every websocket `connect()` path raises `ApiError(headers=dict(headers), ...)` with the full request headers, and both error types stringify that dict, so an unredacted `Authorization` reached `str(e)`, tracebacks, log aggregators and error trackers (which serialise attributes as well as the message). Both now mask credential values at construction via `_secure_logging.redact_sensitive_headers`, preserving non-sensitive headers (`dg-request-id`) for debugging. This is the same threat `_secure_logging.py` covers for the `websockets` DEBUG handshake logs, via the other path to it. Regression coverage in `tests/custom/test_api_error_redaction.py`. Unfreeze if the generator starts redacting credentials itself.
- `src/deepgram/core/query_encoder.py` — coerces Python bools to lowercase `"true"`/`"false"` before they reach `urllib.parse.urlencode` (which would otherwise produce `"True"`/`"False"` via `str()` and break websocket query strings). Only the four `*/connect()` paths call `urlencode`; HTTP raw clients hand params to httpx, which lowercases bools itself, so the patch is a no-op for the HTTP path. Once Fern's websocket codegen normalizes bools (or the spec types these as `boolean` end-to-end), this can be unfrozen.
Expand Down
23 changes: 15 additions & 8 deletions examples/16-transcription-force-end-turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,15 @@
push-to-talk button being released. The connection stays open after a forced end: the
turn index advances and transcription continues.

Note: ForceEndTurn requires server-side enablement and is not available on every
deployment. Where it is not enabled the server replies UNPARSABLE_CLIENT_MESSAGE and
closes the connection; this example reports that and exits.
ForceEndTurn is available on Deepgram-hosted deployments, including EU and AU. On
self-hosted deployments, it requires the 2026-09-15 release or later with
the following [features] settings:

listen_v2 = true
listen_v2_force_end_turn = true

Unsupported deployments reply UNPARSABLE_CLIENT_MESSAGE and close the connection; this
example reports that and exits.
"""

import os
Expand All @@ -50,8 +56,8 @@


def _client_environment() -> DeepgramClientEnvironment:
"""Because ForceEndTurn is gated, this example accepts DEEPGRAM_BASE_URL=wss://<host>
so it can be pointed at a deployment that has the feature. Defaults to production."""
"""Accept DEEPGRAM_BASE_URL=wss://<host> to target a regional or self-hosted deployment.
Defaults to production."""
base = os.environ.get("DEEPGRAM_BASE_URL")
if not base:
return DeepgramClientEnvironment.PRODUCTION
Expand Down Expand Up @@ -116,7 +122,7 @@ def send_audio() -> None:
try:
connection.send_media(audio[i : i + chunk_size])
except Exception:
# A gated deployment closes the socket rather than ignoring the
# An unsupported deployment closes the socket rather than ignoring the
# message, so later sends fail. Stop streaming.
break
time.sleep(0.1)
Expand All @@ -143,12 +149,13 @@ def send_audio() -> None:
# This blocks until the connection closes
connection.start_listening()

# Reported here rather than in the sender thread: on a gated deployment the server
# Reported here rather than in the sender thread: on an unsupported deployment the server
# closes the connection, start_listening() returns, and the daemon thread is killed
# before it could print anything.
if feature_disabled.is_set():
print("\nForceEndTurn is not enabled on this deployment.")
print("Set DEEPGRAM_BASE_URL to a deployment that has the feature.")
print("Self-hosted deployments need the 2026-09-15 release or later with")
print("listen_v2 = true and listen_v2_force_end_turn = true under [features].")

except Exception as e:
print(f"Error: {type(e).__name__}: {e}")
57 changes: 50 additions & 7 deletions examples/32-voice-agent-force-end-turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import threading
import time
from pathlib import Path
from typing import Any

from dotenv import load_dotenv

Expand All @@ -31,11 +32,36 @@
load_dotenv()

AUDIO_PATH = Path(__file__).parent / "fixtures" / "audio.wav"
FORCE_END_TURN_REJECTION_GRACE_SECONDS = 0.5


def wait_for_force_end_turn_outcome(
agent_finished: threading.Event,
force_end_turn_rejected: threading.Event,
*,
timeout_seconds: float,
) -> bool:
"""Return whether ForceEndTurn completed without a queued rejection."""
deadline = time.monotonic() + timeout_seconds
while True:
if force_end_turn_rejected.is_set():
return False

remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("Timed out waiting for the agent response")
if agent_finished.wait(min(0.1, remaining)):
# AgentAudioDone can precede FORCE_END_TURN_UNSUPPORTED in the socket reader.
return not force_end_turn_rejected.wait(min(FORCE_END_TURN_REJECTION_GRACE_SECONDS, remaining))


def main() -> None:
user_started = threading.Event()
agent_finished = threading.Event()
force_end_turn_rejected = threading.Event()
force_end_turn_error: str | None = None
force_end_turn_failed = False
timeout_error: TimeoutError | None = None

settings = AgentV1Settings(
audio=AgentV1SettingsAudio(input=AgentV1SettingsAudioInput(encoding="linear16", sample_rate=44100)),
Expand All @@ -55,7 +81,8 @@ def main() -> None:

with DeepgramClient().agent.v1.connect() as agent:

def on_message(message: object) -> None:
def on_message(message: Any) -> None:
nonlocal force_end_turn_error
message_type = getattr(message, "type", None)
if message_type == "UserStartedSpeaking":
user_started.set()
Expand All @@ -67,6 +94,9 @@ def on_message(message: object) -> None:
print("AgentAudioDone received")
elif message_type in {"Warning", "Error"}:
print(f"{message_type}: {message.code} - {message.description}")
if message.code == "FORCE_END_TURN_UNSUPPORTED":
force_end_turn_error = f"{message.code}: {message.description}"
force_end_turn_rejected.set()

agent.on(EventType.MESSAGE, on_message)
agent.on(EventType.ERROR, lambda error: print(f"Connection error: {error}"))
Expand All @@ -83,13 +113,26 @@ def on_message(message: object) -> None:
if user_started.is_set():
break

# Errors are raised after the connection closes, so the close does not
# report a spurious connection error on top of them.
if not user_started.is_set():
raise TimeoutError("Timed out waiting for UserStartedSpeaking")

print("Sending ForceEndTurn")
agent.send_force_end_turn()
if not agent_finished.wait(15):
raise TimeoutError("Timed out waiting for the agent response")
timeout_error = TimeoutError("Timed out waiting for UserStartedSpeaking")
else:
print("Sending ForceEndTurn")
agent.send_force_end_turn()
try:
force_end_turn_failed = not wait_for_force_end_turn_outcome(
agent_finished,
force_end_turn_rejected,
timeout_seconds=15,
)
except TimeoutError as error:
timeout_error = error

if timeout_error is not None:
raise timeout_error
if force_end_turn_failed:
raise RuntimeError(f"ForceEndTurn failed: {force_end_turn_error}")


if __name__ == "__main__":
Expand Down
2 changes: 1 addition & 1 deletion reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -5431,7 +5431,7 @@ asyncio.run(main())
**`send_force_end_turn()`** — Force the current turn to end immediately

- `connection.send_force_end_turn()` — The resulting `EndOfTurn` event reports `trigger="manual"` (turns ended by end-of-turn detection report `"model"`, and turns ended by `eot_timeout_ms` report `"timeout"`)
- Requires server-side enablement; deployments without the feature reject the message and close the connection
- Available on Deepgram-hosted deployments (including EU and AU). Self-hosted deployments need the 2026-09-15 release or later with the ForceEndTurn feature enabled; unsupported deployments reply UNPARSABLE_CLIENT_MESSAGE and close the connection

</dd>
</dl>
Expand Down
105 changes: 105 additions & 0 deletions tests/custom/test_voice_agent_force_end_turn_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import importlib.util
import sys
import threading
import types
from pathlib import Path

import pytest

EXAMPLE_PATH = Path(__file__).parents[2] / "examples" / "32-voice-agent-force-end-turn.py"


class RejectionDuringGraceWait(threading.Event):
def __init__(self):
super().__init__()
self.wait_calls = 0

def wait(self, _timeout):
self.wait_calls += 1
self.set()
return True


def load_example(monkeypatch):
dotenv = types.ModuleType("dotenv")
dotenv.load_dotenv = lambda: None
monkeypatch.setitem(sys.modules, "dotenv", dotenv)

spec = importlib.util.spec_from_file_location("voice_agent_force_end_turn_example", EXAMPLE_PATH)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


def test_agent_audio_done_waits_for_late_force_end_turn_rejection(monkeypatch):
example = load_example(monkeypatch)
agent_finished = threading.Event()
force_end_turn_rejected = RejectionDuringGraceWait()
agent_finished.set()

assert not example.wait_for_force_end_turn_outcome(
agent_finished,
force_end_turn_rejected,
timeout_seconds=1,
)
assert force_end_turn_rejected.wait_calls == 1


def test_force_end_turn_rejection_leaves_context_before_raising(monkeypatch):
example = load_example(monkeypatch)

class FakeAgent:
def __init__(self):
self.message_handler = None
self.user_started = False

def on(self, event, callback):
if event == example.EventType.MESSAGE:
self.message_handler = callback

def start_listening(self):
pass

def send_settings(self, _settings):
pass

def send_media(self, _audio):
if not self.user_started:
self.user_started = True
self.message_handler(types.SimpleNamespace(type="UserStartedSpeaking"))

def send_force_end_turn(self):
self.message_handler(types.SimpleNamespace(type="AgentAudioDone"))
self.message_handler(
types.SimpleNamespace(
type="Warning",
code="FORCE_END_TURN_UNSUPPORTED",
description="ForceEndTurn is unsupported",
)
)

class FakeAgentContext:
def __init__(self):
self.agent = FakeAgent()
self.exit_exception_type = None

def __enter__(self):
return self.agent

def __exit__(self, exc_type, _exc_value, _traceback):
self.exit_exception_type = exc_type

context = FakeAgentContext()

class FakeDeepgramClient:
def __init__(self):
self.agent = types.SimpleNamespace(v1=types.SimpleNamespace(connect=lambda: context))

monkeypatch.setattr(example, "DeepgramClient", FakeDeepgramClient)

with pytest.raises(RuntimeError, match="FORCE_END_TURN_UNSUPPORTED"):
example.main()

assert context.exit_exception_type is None
Loading
Loading