From 67b7341046a81f62326333e861e3cc6b0af16340 Mon Sep 17 00:00:00 2001 From: mayankbohradev Date: Thu, 13 Aug 2026 20:33:24 +0530 Subject: [PATCH 1/2] fix: route Speak V2 websocket through custom transport_factory `_TARGET_MODULES` in transport.py lists the modules that `install_transport()` patches so a user-supplied `transport_factory` replaces the default `websockets` transport. The Speak V2 websocket client shipped in 7.7.0 and binds the same patched symbols, but its two modules were never added to the list. Effect: a caller passing `transport_factory` gets custom-transport routing for Listen v1/v2, Speak v1 and Agent v1, while Speak V2 opens a direct connection to api.deepgram.com. There is no error or warning, so traffic intended to flow through a proxied or custom-hosted transport silently bypasses it. The existing transport tests could not catch this. They iterate `_TARGET_MODULES` itself, so they verify the list is internally consistent but cannot detect a module missing from it -- the missing entry is never iterated. Left as-is, the next regen that adds a websocket client reintroduces the same silent bypass. Changes: - add deepgram.speak.v2.raw_client and deepgram.speak.v2.client to `_TARGET_MODULES` (and correct the now-stale count in the comment) - add a completeness guard that discovers websocket modules from the package source, independently of `_TARGET_MODULES`, and asserts every discovered module is registered Verified: the new guard fails on the unpatched tree naming both Speak V2 modules, and passes after the fix. 319 passed, 1 skipped across tests/custom and tests/utils; `mypy src/` and `mypy tests/typecheck` clean. WireMock-backed tests under tests/wire require Docker and were not run locally. --- src/deepgram/transport.py | 4 ++- tests/custom/test_transport.py | 51 +++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/deepgram/transport.py b/src/deepgram/transport.py index a7897725..b92e3dff 100644 --- a/src/deepgram/transport.py +++ b/src/deepgram/transport.py @@ -27,7 +27,7 @@ # --------------------------------------------------------------------------- # Module paths that contain the websocket references we need to patch. -# All 8 are auto-generated by Fern — we never modify their source. +# All 10 are auto-generated by Fern — we never modify their source. # --------------------------------------------------------------------------- _TARGET_MODULES = [ "deepgram.listen.v1.raw_client", @@ -36,6 +36,8 @@ "deepgram.listen.v2.client", "deepgram.speak.v1.raw_client", "deepgram.speak.v1.client", + "deepgram.speak.v2.raw_client", + "deepgram.speak.v2.client", "deepgram.agent.v1.raw_client", "deepgram.agent.v1.client", ] diff --git a/tests/custom/test_transport.py b/tests/custom/test_transport.py index 84a262f7..80ceff4b 100644 --- a/tests/custom/test_transport.py +++ b/tests/custom/test_transport.py @@ -2,11 +2,13 @@ import json import sys -from typing import Any, Dict, Iterator, List +from pathlib import Path +from typing import Any, Dict, Iterator, List, Set from unittest.mock import MagicMock import pytest +import deepgram from deepgram.transport import ( AsyncTransport, SyncTransport, @@ -530,3 +532,50 @@ def test_async_transport_factory_auto_disables_reconnect(self): from deepgram.client import AsyncDeepgramClient client = AsyncDeepgramClient(api_key="test-key", transport_factory=factory) assert client.reconnect is False + + +# --------------------------------------------------------------------------- +# _TARGET_MODULES completeness +# --------------------------------------------------------------------------- + +_PATCHED_SYMBOLS = ("websockets_sync_client", "websockets_client_connect") + + +def _discover_websocket_modules() -> Set[str]: + """Return every `deepgram` module that references a patchable websocket symbol. + + Derived from the package source, deliberately not from `_TARGET_MODULES`. A + check that iterates `_TARGET_MODULES` can only confirm the list is internally + consistent; it cannot detect a websocket client missing from the list, + because the missing entry is never iterated. + """ + package_root = Path(deepgram.__file__).parent + discovered: Set[str] = set() + + for path in sorted(package_root.rglob("*.py")): + # transport.py names both symbols as its patch targets, so including it + # here would make the scan match itself. + if path.name == "transport.py": + continue + + source = path.read_text(encoding="utf-8") + if any(symbol in source for symbol in _PATCHED_SYMBOLS): + relative = path.relative_to(package_root).with_suffix("") + discovered.add(".".join(("deepgram",) + relative.parts)) + + return discovered + + +class TestTargetModuleCompleteness: + def test_every_websocket_module_is_registered_for_patching(self): + missing = sorted(_discover_websocket_modules() - set(_TARGET_MODULES)) + + assert not missing, ( + "websocket client module(s) absent from _TARGET_MODULES, so a custom " + "transport_factory is silently not applied to them: " + ", ".join(missing) + ) + + def test_discovery_locates_a_known_websocket_module(self): + # Guards the guard: if the scan silently found nothing, the completeness + # check above would pass vacuously. + assert "deepgram.listen.v1.raw_client" in _discover_websocket_modules() From 32b9ff6c53d903d0bcf123bfd385f82bcef687e9 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Tue, 1 Sep 2026 12:54:47 +0100 Subject: [PATCH 2/2] chore: address custom transport review feedback --- AGENTS.md | 2 +- src/deepgram/transport.py | 2 +- tests/custom/test_transport.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4a680b38..58a69508 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ Current permanently frozen files: - `src/deepgram/agent/v1/types/agent_v1settings_agent_context_listen_provider_v1.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_listen_provider_v2.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_listen_provider_v2language_hint.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_listen_provider_v1.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_listen_provider_v2.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_listen_provider_v1.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_listen_provider_v2.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_listen_provider_v2language_hint.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_listen_provider_v1.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_listen_provider_v2.py` — hand-written compatibility aliases for the 2026-05-14 spec dedup that consolidated `AgentV1SettingsAgent[Context]ListenProviderV{1,2,V2LanguageHint}` into top-level `DeepgramListenProvider*` types - `src/deepgram/types/deepgram_listen_provider_v2language_hint.py`, `src/deepgram/requests/deepgram_listen_provider_v2language_hint.py` — hand-written shims recreating the top-level `DeepgramListenProviderV2LanguageHint`/`...Params` type (`Union[str, List[str]]`) that Fern removed in the 2026-06-15 regen. The `*V2LanguageHint` listen-provider aliases above import from these and they remain part of the public import surface, so they are recreated by hand and frozen so Fern won't delete them again - `src/deepgram/listen/v2/types/listen_v2close_stream_type.py` — hand-written shim recreating `ListenV2CloseStreamType`, which Fern removed in the 2026-06-15 regen (docs #946). The original generated type wrongly allowed `Union[Literal["Finalize","CloseStream","KeepAlive"], Any]` (v2 copied v1's control-message enum); a CloseStream message's `type` can only ever be `"CloseStream"`. Recreated as the corrected `Literal["CloseStream"]` to preserve the public import path without resurrecting the invalid values. Re-exported from the three `listen` `__init__.py` files (temporarily frozen, below). -- `src/deepgram/transport_interface.py`, `src/deepgram/transport.py`, `src/deepgram/transports/` — custom transport layer +- `src/deepgram/transport_interface.py`, `src/deepgram/transport.py`, `src/deepgram/transports/` — custom transport layer. `_TARGET_MODULES` in `transport.py` must include every generated `client.py` and `raw_client.py` that uses the patchable WebSocket connector symbols; update it whenever a regen adds a WebSocket surface. `tests/custom/test_transport.py` enforces this so `transport_factory` cannot silently fall back to Deepgram Cloud. - `tests/custom/test_agent_history.py` — hand-written regression test for Agent History websocket payload parsing - `tests/custom/test_compat_aliases.py` — hand-written regression test for backward-compatible alias imports after regen renames - `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 diff --git a/src/deepgram/transport.py b/src/deepgram/transport.py index b92e3dff..2c81c76a 100644 --- a/src/deepgram/transport.py +++ b/src/deepgram/transport.py @@ -104,7 +104,7 @@ def install_transport( sync_factory: Optional[Callable] = None, async_factory: Optional[Callable] = None, ) -> None: - """Monkey-patch the 8 auto-generated modules to use custom transports. + """Monkey-patch the 10 auto-generated modules to use custom transports. Parameters ---------- diff --git a/tests/custom/test_transport.py b/tests/custom/test_transport.py index 80ceff4b..931ed8fc 100644 --- a/tests/custom/test_transport.py +++ b/tests/custom/test_transport.py @@ -10,16 +10,15 @@ import deepgram from deepgram.transport import ( + _TARGET_MODULES, AsyncTransport, SyncTransport, _AsyncTransportShim, _SyncTransportShim, - _TARGET_MODULES, install_transport, restore_transport, ) - # --------------------------------------------------------------------------- # Mock transport implementations # ---------------------------------------------------------------------------